算法

112 阅读6分钟

算法

标签(空格分隔): 前端


[TOC]

排序

快速排序

/**
 * 快速排序
 *
 * 利用 partition 函数将数组以基准值为分界线变成左侧比基准小,右侧比基准大的数组。
 * 然后以得到的左指针为分界线,分别递归快速排序左右两侧。
 *
 */
function quickSort(arr, left = 0, right = arr.length - 1) {
  let len = right - left
  // 共有 2 个元素时也要比较,因为有可能是第一次递归
  if (len <= 0) {
    return arr
  }

  let lineIndex = partition(arr, left, right)

  if (left < lineIndex - 1) {
    quickSort(arr, left, lineIndex - 1)
  }

  if (right > lineIndex + 1) {
    quickSort(arr, lineIndex, right)
  }

  return arr
}

/**
 * 利用数组本身,不临时生成新的子数组。
 * 每次取left right 中间的元素为基准值,
 * 将左侧大于等于基准值的元素挪右边,将右侧小于等于比基准值的元素挪左边,
 * 方式就是将上述的2个值对调。
 * 反复操作,直至左指针跑到了右指针的右侧。
 *
 * 返回左指针的索引
 *
 */
function partition(arr, left, right) {
  // 这里别忘了加上 left
  let pivotValue = arr[Math.floor(left + (right -left)/2)]
  let i = left
  let j = right

  while (i<=j){
    while (arr[i] < pivotValue){
      i++
    }

    while (arr[j] > pivotValue){
      j--
    }

    if (i <= j) {
      ;[arr[i], arr[j]] = [arr[j], arr[i]]
      i++
      j--
    }
  }

  return i
}

let aquick = [5, 1, 3, 2.5, 2, 0, 7]
console.log(quickSort(aquick))

动态规划

dp dynamic planning 看到求最值问题如最长回文字符串、最长上升子序列,就要想到动态规划,去找两个状态直接的关系,如回文字符串内部肯定也是回文字符串。

两个特点:最优子结构 重叠子问题

最优子结构,它指的是问题的最优解包含着子问题的最优解——不管前面的决策如何,此后的状态必须是基于当前状态(由上次决策产生)的最优决策。就这道题来说,f(n)和f(n-1)、f(n-2)之间的关系印证了这一点(这玩意儿叫状态转移方程,大家记一下)。

重叠子问题,它指的是在递归的过程中,出现了反复计算的情况。就这道题来说,图上标红的一系列重复计算的结点印证了这一点。
因此,这道题适合用动态规划来做。
  • 假设已经到达终点,而不是差一步到终点怎么去终点。
  • 然后从终点往后退,得到状态转移关系。
  • 结合记忆化搜索,明确状态转移方程。
  • 可能得到一个或2个自变量。利用初始化初始值以及类似Math.max等筛选机制转化跳跃式挑选为遍历,从而转为可编码思路。

背包问题

juejin.cn/book/684473…

# mark
1."那么可能被取出的物品就有 i 种可能性" 这句话为了便于新手理解,是不是用 "那么可能被取出的物品就有 n 种可能性,只是其中某个物品要么存在,要么不存在" 更合适?
后续都用 n 表达,f(n, c) = f(n-1, c-w[n]) + value[n]。
然后 dp[i][v] = Math.max(dp[i-1][v], dp[i-1][v-w[i]] + value[i]) 用 i 表达,因为要用来遍历【最优子结构,每一步都只和前一步有关,当前值就是允许体积内价值最大的选择】。
2.为什么可以按照数组的顺序取出?因为物品状态被我们设为可以是存在或不存在,用 Math.max 来取最大值。
3. dp[i][v] = Math.max(dp[i-1][v], dp[i-1][v-w[i]] + c[i]) 中, c[i] 应该是 value[i]。
4.最终编码中,因为 const dp = (new Array(c+1)).fill(0) 初始化为 0【价值为0】,保证了不存在的 dp[v] 在 Math.max 中被丢弃【value[i]】。

# 滚动数组
外层一行行遍历,内层从大到小遍历,当前位置在更新时能使用旧数据,且之后再往前遍历的过程中用不到当前位置的旧数据,所以可以安心的替换当前位置的数据为新数据。

# 最后代码
/**
 * 背包问题,求能得到的最大价值【只有体积、价值,没有重量】
 *
 * @param c     背包总容积
 * @param n     物品数量
 * @param w     物品体积列表
 * @param value 物品价值列表
 * @returns {number}
 */
function knapsack(c, n, w, value) {
  // 因为dp[v]循环的边界是 c,初始化价值为0
  let dp = (new Array(c+1)).fill(0)

  // 一层层遍历导致dp中的最大值越来越大【或者不变】,其中有相同的值,因为内层循环中用的同一个 value[i]
  console.log(dp)
  for (let i = 0; i < n; i++) {
    console.log(`第${i}轮外循环`)
    // v索引表示体积,内循环的边界是 c和w[i],c是总容量毋庸置疑,
    // 用w[i],是因为下面要找dp[v-w[i]]的值,dp最小索引是dp[0],即包包容积为0,不能为负数啊
    for (let v = c; v >= w[i]; v--) {
      // dp[v]表示之前当前体积的最大总价值
      // dp[v-w[i]]+value[i]表示从体积不到v处放入当前物品w[i]后,体积刚好达到v后,得到的新的最大总价值
      // 如果后者还没有前者的值大,表示后者不如当前状态。那就在内循环中保持当前状态,否则替换为后者的方式。
      // 再经过每一轮的外层循环,不停的比较dp[v]处的最大可能值。
      dp[v] = Math.max(dp[v], dp[v-w[i]]+value[i])
    }
  }

  // dp[c]肯定是最大的,因为c是最大的容量,肯定能放更多值钱的东西
  return dp[c]
}

let c = 10
let n = 3
let w = [7,4,1]
let value = [15,30,20]

console.log(knapsack(c, n, w, value)) // 50

微软真题

juejin.cn/book/684473…

最长回文字符串

let str = 'babad'

/**
 * 最长回文字符串
 *
 * @param str
 * @returns {string}
 */
function longestPalindrome(str) {
  let len = str.length

  // 空字符串的话直接返回空字符串
  if (!len) {
    return ''
  }

  // 返回值数组,不同长度的回文字符串的位置
  let res = []

  // 初始化二维数组 dp[i][j] 表示从i到j的子字符串是否为回文字符串
  let dp = []
  for (let i = 0; i < len; i++) {
    dp[i] = []
  }

  // 外层遍历从尾部开始,是因为内部有 dp[i][j] = dp[i+1][j-1] 使用了字符串后面的数据
  for (let i = len-1; i >= 0; i--) {
    // 内部遍历从i开始,j<i时不是字符串了
    for (let j = i; j < len; j++) {
      if (str[i] === str[j]) {
        if (i+1 > j-1) {
          // i === j || i === j - 1
          dp[i][j] = 1
        } else {
          // 这里顺便处理了i==j的情况
          dp[i][j] = dp[i+1][j-1]
        }
      } else {
        // 两头不相等时,这段字符串直接肯定不回文了
        dp[i][j] = 0
      }

      // 本段字符串回文时,保存进相应长度的结果数组中
      if (dp[i][j] === 1) {
        let l = j - i + 1
        if (!res[l]) {
          res[l] = []
        }
        res[l].push({i, j})
      }
    }
  }

  // 从最长结果中随便取一个,到这里 res 肯定有值,至少是一个字符
  let {i, j} =  res[res.length -1][0]

  return str.substring(i, j+1)
}

console.log(longestPalindrome(str))

从先序遍历和中序遍历生成二叉树

/**
 * 构造树节点
 * @param val
 * @constructor
 */
function TreeNode(val) {
  this.val = val
  this.left = this.right = null
}

/**
 * 从先序遍历和中序遍历生成二叉树
 * 微软真题
 * 
 * 小册的方案中用了numLeft
 * 不过因为一直是同一个数组,感觉不用用 numLeft,直接 k 就是真实的位置,更直观
 *
 * 中序遍历中,根节点右侧肯定是右树。
 * 先序的根节点加左树的节点的总数和中序的根节点及左侧的节点总数是相等的。
 * @param preOrder
 * @param inOrder
 * @returns {TreeNode}
 */
function buildTree2(preOrder, inOrder) {
  let len = preOrder.length

  function build(preL, preR, inL, inR) {
    // 要同时判断两个数组
    if (preL > preR || inL > inR) {
      return null
    }

    let root = new TreeNode()
    root.val = preOrder[preL]
    let k = inOrder.indexOf(root.val)

    root.left = build(preL+1, k, inL, k - 1)
    root.right = build(k + 1, preR, k+1, inR)

    return root
  }

  return build(0, len - 1, 0, len - 1)
}

let preOrder = [3, 9, 20, 15, 7]
let inOrder = [9, 3, 15, 20, 7]

console.log(buildTree2(preOrder, inOrder))

复制带随机指针的链表

只替换val而不新生成Node,就可以不用dummy节点

/**
 * 复制带随机指针的链表
 * 
 * @param head
 * @returns {Node|null}
 */
function copyRandomList(head) {
  if (!head) {
    return null
  }
  let map = new Map()
  let cur = head
  let copyHead = new Node()
  let copyCur = copyHead

  while (cur) {
    // 只替换val而不新生成Node,就可以不用dummy
    copyCur.val = cur.val
    copyCur.next = cur.next ? new Node() : null
    map.set(cur, copyCur)

    cur = cur.next
    copyCur = copyCur.next
  }

  cur = head
  copyCur = copyHead
  while (cur){
    if (cur.random) {
      copyCur.random = map.get(cur.random)
    }

    cur = cur.next
    copyCur = copyCur.next
  }

  return copyHead
}

google真题

岛屿问题

/**
 * 岛屿数量问题
 * google真题
 *
 * @param grid
 * @returns {number}
 */
function numIslands(grid) {
  // 界定边界
  if (!grid || !grid.length || !grid[0].length) {
    return 0
  }
  
  let row = grid.length
  let count = 0
  let moveX = [0, 1, 0, -1]
  let moveY = [1, 0, -1, 0]
  
  for (let i = 0; i < row; i++) {
    // 这里用实时的行的长度而不用第一行的长度,是为了处理下方有些岛屿超过第一行最右侧突出在右边的情况
    for (let j = 0; j < grid[i].length; j++) {
      // 是岛屿的话,可以向右试探也可以向下试探
      if (grid[i][j] == '1') {
        dfs(i, j)
        count++
      }
    }
  }
  
  function dfs(i, j) {
    // 触碰到边界或海水就停止,其他区域由其他的dfs扫到
    if (i < 0 || i >= row || j < 0 || j >= grid[i].length || grid[i][j] != '1') {
      return
    }
    
    grid[i][j] = '0'

    // 围绕当前岛屿扫一圈,会递归的把当前岛屿能连接的岛屿全部置为0
    for (let k = 0; k < 4; k++) {
      dfs(i + moveX[k], j + moveY[k])
    }
  }
  
  return count
}

let grid = [  [1,1,0,1],
  [1,1,0,1],
  [1,1,0,0],
  [0,1,0,0,1],
]

console.log(numIslands(grid)) // 3

扫地机器人问题

扫地机器人问题中,在某个位置进行四个方向试探时,一个方向结束后回到原处原方向,然后才向右转的【向右转的代码在后面】。 所以四个方向试探完毕回到原位时,跟刚开始进入该位置时的方向时相同的。 用if(robot.move())去判断边界,自己也不用维持数组,另外刚开始的dir也不用声明,因为根本没用,dir是dfs的局部变量。 dir是不是可以全局唯一,不用通过传参。

腾讯真题

juejin.cn/book/684473…

寻找最近的公共祖先

一层层往上返回往下找时得到的节点。 返回当前节点的情况:

  • 当前节点为null
  • 当前节点等于 p || q
  • 当前节点的左右子树均为真

要考虑清楚只有一侧子树汇报的情况,return leftNode || rightNode

  • 查 6 2 时,5被一路汇报到3,最后返回出去
  • 查 5 6 时,到5就停了,其实不会再往下去查5的左右子树,然后5被一路返回上去,知道最后作为dfs(root)时的返回值。

寻找两个正序数组的中位数

记得用 -Infinity Infinity 表示最小值 最大值。

sliceR 初始化时用长度 len 而不用 len -1 是因为 Math.floor(-0.5)为 -1, // 导致 sliceL 长到 len后, 下面的 slice1 挪不动【sliceL 增大了,可是 slice1却又减去了增大的量,保持不动】

/**
 * 寻找两个正序数组的中位数
 * 腾讯真题
 *
 * 并不非得是找正中间,而是如果nums1 nums2 中长度是奇数的话,取中位的某个元素
 * 如 [1, 3, 5, 7, 9, 11] [2, 4, 5.4, 8, 10] 返回5.4
 * @param nums1
 * @param nums2
 */
function findMedianSortedArrays(nums1, nums2) {
  let len1 = nums1.length
  let len2 = nums2.length
  let len = len1 + len2

  // 确保第一个入参数组总是短的那个
  if (len1 > len2) {
    return findMedianSortedArrays(nums2, nums1)
  }

  // 初始化 nums1 nums2 下刀二分位置
  let slice1 = 0
  let slice2 = 0

  // 初始化 nums1 二分范围的左右端点
  let sliceL = 0
  // 这里用 len 而不用索引 len -1 是因为 Math.floor(-0.5)为 -1,
  // 导致 sliceL 长到 len后, 下面的 slice1 挪不动【sliceL 增大了,可是 slice1 却又减去了增大的量,保持不动】
  let sliceR = len1

  let L1, R1, L2, R2

  while (slice1 <= len1) {
    slice1 = Math.floor((sliceR - sliceL) / 2) + sliceL
    slice2 = Math.floor(len/2) - slice1

    console.log('slice1 slice2', slice1, slice2)

    L1 = nums1[slice1 - 1] || -Infinity
    R1 = nums1[slice1] || Infinity // 这里保证slice1到了边界以后能进 else ,然后 return 掉,不会再往右了
    L2 = nums2[slice2 - 1] || -Infinity
    R2 = nums2[slice2] || Infinity
    
    // 不行的话,先挪一步,然后在下一个 while 循环中再次二分得到新的 slice1,
    // Math.floor 那里就算结果为 0 也能因为这里挪了一步而导致slice1被挪动
    if (L1 > R2) {
      sliceR = slice1 - 1
    } else if (L2 > R1) {
      // sliceL 可能能增大到 len
      sliceL = slice1 + 1
    } else {
      if (len % 2) {
        return R1 > R2 ? R2 : R1
      } else {
        let L = L1 > L2 ? L1 : L2
        let R = R1 > R2 ? R2 : R1

        return (L + R)/2
      }
    }
  }

  return -1
}

console.log(findMedianSortedArrays([1, 2, 3], [7, 8, 9]))

粉刷房子问题

/**
 * 粉刷房子问题
 * 腾讯真题
 *
 * @param costs
 * @returns {any[]}
 */
function minCost2(costs) {
  let len = costs.length
  let dp = new Array(len)

  dp[0] = costs[0][0]
  dp[1] = costs[0][1]
  dp[2] = costs[0][2]

  for (let i = 1; i < len; i++) {
    let [x, y, z] = dp
    dp[0] = Math.min(y, z) + costs[i][0]
    dp[1] = Math.min(x, z) + costs[i][1]
    dp[2] = Math.min(x, y) + costs[i][2]
  }

  return dp
}

let costs = [[17,2,17],[16,16,5],[14,3,19]]
console.log(minCost2(costs))

头条真题

接雨水问题

let arr = [0,1,0,2,1,0,1,3,2,1,2,1]

function getRain(arr) {
  let len = arr.length

  // 初始化两头的指针。一轮对撞就把结果求出来了。
  let leftCur = 0
  let rightCur = len -1

  // 初始化为 0,能够在下面的 while 循环中把两头的 0 的给跳过
  let leftMax = 0
  let rightMax = 0

  // 返回值,初始化为 0
  let res = 0

  // 不能有等号,相等就是同一个柱子了
  while (leftCur < rightCur){
    let left = arr[leftCur]
    let right = arr[rightCur]
    console.log(left, right)

    // 一个地方能存的水,不是由它旁边的柱子决定,
    // 而是由它左侧最高处柱子和右侧最高处柱子中的较矮的那个决定
    // 为什么这里用 left < right 判断?这两个相距还挺远的啊
    // 里面包含相等、为0等情况,加0等于没加,如果left目前小,肯定是左侧可以决定高度。
    // 一侧走到高的地方的时候,另外一个侧一直走到高于这个地方或者到头为止。
    // 还是那句话,水位是有洼地两侧最高处柱子总较矮的那个决定。
    // 一开始左边找到边界以后,下一轮就会从右边开始找边界,没有边界的话,会一直把右边的吃光,那 res 最终就是 0 了
    if (left < right) {
      // 遇见旧的 leftMax <= left 时,
      // leftMax 就变为新的 left, 那么leftMax - left就是 0,这就是水的边界。
      // 只有 旧的 leftMax > left 时,表示开始向下洼地了,才能存水。
      // 左侧想通了的话,右侧是对称的。
      leftMax = Math.max(left, leftMax)
      // 没遇见洼地的话,leftMax === left,那加的就是 0
      res += leftMax - left
      leftCur++
    } else {
      rightMax = Math.max(right, rightMax)
      res += rightMax - right
      rightCur--
    }
  }

  return res
}

console.log(getRain(arr))

K个一组翻转链表

/**
 * k 个一组翻转链表
 *
 * @param head
 * @param k
 * @returns {null}
 */
function reverseKGroup(head, k) {
  let dummy = new ListNode()
  dummy.next = head
  let pre = dummy
  let start = head
  let end = head
  let next = head

  // 完全翻转一个链表
  function reverse(head) {
    let pre = null
    let cur = head

    while (cur){
      let next = cur.next

      cur.next = pre
      pre = cur
      cur = next
    }

    return pre
  }

  // 真正的节点存在的话,往后试探看能否局部完全翻转,
  while (next) {
    // 将 end 再次往前走到第 k 个节点
    // 这里要判断 end 是否存在,不存在的话就不能往前走了
    for (let i = 1; i < k && end; i++) {
      end = end.next
    }

    // 如果 end 不存在,说明剩下的节点不够 k 个了,不用局部完全翻转了,
    // 就不走下面的逻辑了,而且 while 也不再循环了
    if (!end) {
      break
    }

    // 记住断掉之前的下一个节点
    next = end.next
    // 下面用到 reverse,要临时把小段链表断掉
    end.next = null
    // 局部完全翻转后跟前面接上,函数传参是值传递而已
    pre.next = reverse(start)
    // 局部完全翻转后,start从局部链表的头部变成了尾部,跟后面的链表接上
    start.next = next

    // 给下一轮 while 循环赋予初始值
    pre = start
    start = next
    end = next
  }

  return dummy.next
}

let l1 = {
  val: 1,
  next: {
    val: 2,
    next: {
      val: 3,
      next: {
        val: 4,
        next: {
          val: 5
        }
      }
    }
  }
}

function ListNode(val) {
  this.val = val
  this.next = null
}
console.log(JSON.stringify(reverseKGroup(l1, 2)))