前端算法 | 数组篇

203 阅读23分钟

本文是作者刷算法题之余,将刷题的经验分享出来,欢迎和我交流探讨。

(Easy) —— 两数之和

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target  的那 两个 整数,并返回它们的数组下标。

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。

你可以按任意顺序返回答案。

示例 1:

输入: nums = [2,7,11,15], target = 9
输出: [0,1]
解释: 因为 nums[0] + nums[1] == 9 ,返回 [0, 1]

示例 2:

输入: nums = [3,2,4], target = 6
输出: [1,2]

示例 3:

输入: nums = [3,3], target = 6
输出: [0,1]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 数组
  • 求和

  我们先按照最容易想到的思路进行求解:双重 for 循环,这样就得到了数组中的 2 个元素,将他们求和,判断和是否等于 target。这种方法最为简单粗暴,一般称为暴力破解

暴力破解

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
var twoSum = function (nums, target) {
  const len = nums.length
  // 根据题意一定有解,内部 for 一定会 return,无需限定外部 for 终止条件
  for (let i = 0; ; i++) {
    for (let j = i + 1; j < len; j++) {
      // 满足要求,return
      if (nums[i] + nums[j] === target) {
        return [i, j]
      }
    }
  }
  // 根据题意一定有解,无需在结尾 return []
}
  • 时间复杂度:O(n^2),双重for循环

  • 空间复杂度:O(1),变量数跟问题规模无关


  暴力破解虽然可以解出这道题,但实际上一般一定会有比它更好的方式。从代码来看,对同一个数组进行了 2 次循环,仅仅是为了求和,效率非常的低呀!

  其实,对于求和问题,往往可以转化为求差问题

  我们可以将遍历过的元素保存在一个对象 obj 中,key 为元素值,value 为元素索引。并计算 diff = target - 当前遍历的元素值,如果 diffobj 中存在,那么就结束遍历 return [obj[diff]、curIdx] 即可,否则就继续遍历,反正题目一定有答案。这种借助一个额外对象做存储减少时间复杂度的方式一般称为空间换时间

空间换时间 这里我们用 ES6 的 Map字典 来替代普通对象

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
var twoSum = function (nums, target) {
  const len = nums.length
  const map = new Map()
  for (let i = 0; i < len; i++) {
    const diff = target - nums[i] // 求差值
    if (map.has(diff)) {
      return [i, map.get(diff)]
    }
    map.set(nums[i], i)
  }
}
  • 时间复杂度:O(n),一次for循环
  • 空间复杂度:O(n),map的开销与问题规模正相关

总结

  • 对于求和问题,要有条件反射,想到求差
  • 优化双重 for 循环,想到空间换时间

(Easy) —— 合并两个有序数组

给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。

请你 合并 nums2 **到 nums1 中,使合并后的数组同样按 非递减顺序 排列。

注意: 最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n 。

 

示例 1:

输入: nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出: [1,2,2,3,5,6]
解释: 需要合并 [1,2,3][2,5,6] 。
合并结果是 [1,2,2,3,5,6] ,其中斜体加粗标注的为 nums1 中的元素。

示例 2:

输入: nums1 = [1], m = 1, nums2 = [], n = 0
输出: [1]
解释: 需要合并 [1][] 。
合并结果是 [1]

示例 3:

输入: nums1 = [0], m = 0, nums2 = [1], n = 1
输出: [1]
解释: 需要合并的数组是 [][1] 。
合并结果是 [1] 。
注意,因为 m = 0 ,所以 nums1 中没有元素。nums1 中仅存的 0 仅仅是为了确保合并结果可以顺利存放到 nums1 中。

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 数组
  • 合并
  • 排序

  从JS的角度出发,合并(原地)和排序都有现成的 API:splicesort,由此,我们可以从 API 角度先合并后排序解答此题。

先合并后排序

/**
 * @param {number[]} nums1
 * @param {number} m
 * @param {number[]} nums2
 * @param {number} n
 * @return {void} Do not return anything, modify nums1 in-place instead.
 */
var merge = function (nums1, m, nums2, n) {
  // 原地合并 nums1
  nums1.splice(m, n, ...nums2)
  // 对 nums 排序
  nums1.sort((a, b) => a - b)
}

  除了操作 API,是否可以以更加贴近算法的方式 solve 这道题呢?

  当看到关键词:数组排序的时候,应该快速的在脑海闪过指针的思路,一个指针不够那就两个,不过一般最多也就两个。

  这道题,选用双指针是最好不过的了。

  两个指针,ij 初始时分别在 nums1nums2 有实际元素的尾部,另有一个指针 k 仅用于更新 nums1 尾部元素的值。比较 nums1[i] 和 nums2[j] 的大小

  • 如果 nums1[i] > nums2[j],那么 nums1[k--] = nums1[i--]
  • 如果 nums1[i] < nums2[j],那么 nums1[k--] = nums2[j--]

  存在一种特殊情况,某个数组的指针到了最左端(你值大的挺多啊),另一个数组的指针还在中间的情况。

  • 如果 nums1 的元素已遍历完,nums2 的元素有剩余,把 nums2 的元素平移到 nums1 中即可
  • 如果 nums2 的元素已遍历完,nums1 的元素有剩余,不用管。nums1 本身就是有序而且题目要求合并到 nums1

双指针比大小排序

/**
 * @param {number[]} nums1
 * @param {number} m
 * @param {number[]} nums2
 * @param {number} n
 * @return {void} Do not return anything, modify nums1 in-place instead.
 */
var merge = function(nums1, m, nums2, n) {
    let i = m - 1, j = n - 1, k = m + n - 1;
    while (i >= 0 && j >= 0) {
        if (nums1[i] > nums2[j]) {
            nums1[k--] = nums1[i--]
        } else {
            nums1[k--] = nums2[j--]
        }
    }

    // 处理 nums2 剩余的情况
    while (j >= 0) {
        nums1[k--] = nums2[j--]
    }
};
  • 时间复杂度:O(n),指针最多移动2n次
  • 空间复杂度:O(1),原地修改

总结

  • 关键词有数组排序,想到双指针

(Easy) —— 用栈实现队列

请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(pushpoppeekempty):

实现 MyQueue 类:

  • void push(int x) 将元素 x 推到队列的末尾
  • int pop() 从队列的开头移除并返回元素
  • int peek() 返回队列开头的元素
  • boolean empty() 如果队列为空,返回 true ;否则,返回 false

说明:

  • 你 只能 使用标准的栈操作 —— 也就是只有 push to toppeek/pop from topsize, 和 is empty 操作是合法的。
  • 你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。

 

示例 1:

输入:
["MyQueue", "push", "push", "peek", "pop", "empty"]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 1, 1, false]

解释:
MyQueue myQueue = new MyQueue();
myQueue.push(1); // queue is: [1]
myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue)
myQueue.peek(); // return 1
myQueue.pop(); // return 1, queue is [2]
myQueue.empty(); // return false

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 队列
  • 栈实现队列

  先入后出队列先入先出队列 都使用 数组 来模拟。入栈出栈 对应的 apipush()pop()入队出队 对应的 apipush()shift()

  用 模拟 队列,就是 让栈底的元素首先被取出,也就是让 出栈顺序逆序。

  要实现 出栈 逆序,我们可以借助另一个 。假设原始栈为 stack1、辅助栈为 stack2。我们把 stack1 所有元素出栈入栈一次,放到 stack2 中,利用 stack2 专门来出栈,就可以实现 出栈 逆序。

  • 入栈 时,元素加入到 stack1
  • 出栈 时,将 stack2 中的元素出栈,如果 stack2 为空,将 stack1 中的元素出栈入栈放到 stack2 中,再将 stack2 的栈顶元素出栈即可

利用辅助栈实现出栈逆序

var MyQueue = function () {
  this.stack = [] // 原始栈
  this.helperStack = [] // 辅助栈,专门用于出栈
}

/**
 * @param {number} x
 * @return {void}
 */
MyQueue.prototype.push = function (x) {
  this.stack.push(x)
}

/**
 * @return {number}
 */
MyQueue.prototype.pop = function () {
  // 如果辅助栈不为空,辅助栈出栈即可
  // 如果辅助栈为空,原始栈不为空,将原始栈出栈入栈到辅助栈,再将辅助栈出栈
  if (!this.helperStack.length && this.stack.length) {
    while (this.stack.length) {
      this.helperStack.push(this.stack.pop())
    }
  }
  return this.helperStack.pop()
}

/**
 * @return {number}
 */
MyQueue.prototype.peek = function () {
  // 如果辅助栈不为空,获取辅助栈栈顶元素即可
  // 如果辅助栈为空,原始栈不为空,将原始栈出栈入栈到辅助栈,再获取辅助栈栈顶元素
  if (!this.helperStack.length && this.stack.length) {
    while (this.stack.length) {
      this.helperStack.push(this.stack.pop())
    }
  }
  return this.helperStack[this.helperStack.length - 1]
}

/**
 * @return {boolean}
 */
MyQueue.prototype.empty = function () {
  // 原始栈 和 辅助栈 都为空时,队列才为空
  return !this.stack.length && !this.helperStack.length
}

/**
 * Your MyQueue object will be instantiated and called as such:
 * var obj = new MyQueue()
 * obj.push(x)
 * var param_2 = obj.pop()
 * var param_3 = obj.peek()
 * var param_4 = obj.empty()
 */

总结

  • 辅助栈 用于实现 出栈逆序

(Medium) —— 三数之和

给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a ,b ,c  使得 a + b + c = 0 ?请找出所有和为 0 且 不重复 的三元组。

示例 1:

输入: nums = [-1,0,1,2,-1,-4]
输出: [[-1,-1,2],[-1,0,1]]

示例 2:

输入: nums = []
输出: []

示例 3:

输入: nums = [0]
输出: []

  两数之和的升级版,难度也从 Easy 升级到了 Medium

  数组的常客 指针,这道题乍一看需要三个指针,不过其实 for 循环遍历也可以代替一个指针,也就是 for循环 + 双指针。不过我们既然使用了 双指针,记得要先对数组排序。

  for 循环作为最左的一个指针,每次循环,都初始化剩余两个指针为 j = i+1k = arr.length - 1

  对三个指针元素求和,记为 sum

  • 如果 sum > 0,说明 arr[k] 大了,k--
  • 如果 sum < 0,说明 arr[j] 小了,j++

  题目要求每个答案之间不重复,说明三个指针每次移动时,都要考虑是不是跟移动之前值相同,如果相同,则跳过。所有移动情况有:

  • for循环 的自然移动,使用 continue语句 跳过
  • jk 的指针使用 ++ -- 跳过

对撞指针 + 跳过重复

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var threeSum = function (nums) {
  const res = [] // 结果数组
  const len = nums.length
  nums.sort((a, b) => a - b)
  for (let i = 0; i < len - 2; i++) {
    // 跳过重复
    if (i > 0 && nums[i] === nums[i - 1]) {
      continue
    }
    // 初始化左右指针
    let k = i + 1
    let j = len - 1
    while (k < j) {
      const sum = nums[i] + nums[k] + nums[j]
      if (sum > 0) {
        j--
        // 跳过重复
        while (k < j && nums[j] === nums[j + 1]) {
          j--
        }
      } else if (sum < 0) {
        k++
        // 跳过重复
        while (k < j && nums[k] === nums[k - 1]) {
          k++
        }
      } else {
        res.push([nums[i], nums[j], nums[k]])
        k++
        j--
        // 跳过重复
        while (k < j && nums[j] === nums[j + 1]) {
          j--
        }
        // 跳过重复
        while (k < j && nums[k] === nums[k - 1]) {
          k++
        }
      }
    }
  }
  return res
}

总结

  • 使用双指针,往往要先进行排序
  • for循环 也可以当做一个 指针,跳过使用 continue

(Medium) —— 每日温度

给定一个整数数组 temperatures ,表示每天的温度,返回一个数组 answer ,其中 answer[i] 是指对于第 i 天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0 来代替。

 

示例 1:

输入: temperatures = [73,74,75,71,69,72,76,73]
输出: [1,1,4,2,1,1,0,0]

示例 2:

输入: temperatures = [30,40,50,60]
输出: [1,1,1,0]

示例 3:

输入: temperatures = [30,60,90]
输出: [1,1,0]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 数组
  • 区间最值

  看到这道题的时候,最先想到了双指针来比两个指针的大小。但是双指针比大小的方式时间复杂度是 O(n^2),左指针n次 * 右指针n次。

  对于区间最值问题,一般可以考虑单调栈单调队列。这道题,我们维护一个 单调递减栈(因为要将区间的最高温度“弹出”),为了方便拿取元素进行计算,栈由温度的 索引 组成。通过遍历的方式入栈,如果当前温度小于栈顶温度,正常入栈;如果当前温度大于栈顶温度,依次将栈顶元素出栈(这时候就可以计算栈顶元素的“下一个更高温度出现在第一天”),直至当前温度小于栈顶温度,将当前温度索引入栈。

  出栈元素的下一个更高温度是几天后计算出来了,还在栈里的元素的下一个更高温度是几天后也计算出来了,统一是0,因为他们的温度一直再降。所以我们初始化结果数组的时候把每个成员的值都设置为0。

单调栈计算区间最值

/**
 * @param {number[]} temperatures
 * @return {number[]}
 */
var dailyTemperatures = function (temperatures) {
  const len = temperatures.length
  const stack = [] // 单调递减栈,温度递减,栈里放温度
  const res = new Array(len).fill(0) // 初始化结果数组,每个值设置为0
  for (let i = 0; i < len; i++) {
    // 栈不为空 && 新温度打破了单调栈趋势,将栈顶较小温度出栈
    while (
      stack.length &&
      temperatures[i] > temperatures[stack[stack.length - 1]]
    ) {
      let topIdx = stack.pop() // 栈顶元素记录的是索引
      // 计算 当前栈顶温度值与第一个高于它的温度值 的索引差值,也就是“几天后升温”
      res[topIdx] = i - topIdx
    }
    stack.push([i]) // 栈里维护索引,方便计算
  }
  return res
}

总结

  • 单调栈 可用于解决 区间最值 问题,空间换时间,有效降低时间复杂度

(Medium) —— 最小栈

设计一个支持 push ,pop ,top 操作,并能在常数时间内检索到最小元素的栈。

实现 MinStack 类:

  • MinStack() 初始化堆栈对象。
  • void push(int val) 将元素val推入堆栈。
  • void pop() 删除堆栈顶部的元素。
  • int top() 获取堆栈顶部的元素。
  • int getMin() 获取堆栈中的最小元素。

 

示例 1:

输入:
["MinStack","push","push","push","getMin","pop","top","getMin"]
[[],[-2],[0],[-3],[],[],[],[]]

输出:
[null,null,null,null,-3,null,0,-2]

解释:
MinStack minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
minStack.getMin();   --> 返回 -3.
minStack.pop();
minStack.top();      --> 返回 0.
minStack.getMin();   --> 返回 -2.

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 设计数据结构
  • 常数时间复杂度O(1)

  使用数组来实现,pushpoptop 操作都比较好弄,分别借助原生的 pushpop数组[len - 1] 就可以实现,且都是 O(1) 时间复杂度。难点在于 getMin 方法,一般的思路是遍历一遍栈,得到最小元素,但这个操作的时间复杂度是 O(n)

  想要减小时间复杂度,可以从 空间换时间 的角度思考,能不能提供一个额外的 最小栈,确保它的栈顶是最小的元素。

  具体的设计思路:维护一个 数据栈、一个 最小栈数据栈 跟一般的栈没什么两样,栈里有所有的元素,数据栈 用来实现 pushpoptop最小栈 确保栈顶元素是所有元素里最小的,我们这里主要看一下 最小栈 的实现思路:

  • push 的时候,如果 最小栈 为空,那就把元素添加到 最小栈;如果元素小于 最小栈 栈顶元素,那就 push最小栈 栈顶;如果元素大于 最小栈 栈顶元素,不对 最小栈 做任何操作
  • pop 的时候,是对 数据栈pop,但是如果 最小栈 栈顶也有这个元素,证明这个元素曾经最小过,为了数据的准确性,也需要对此时 最小栈 的栈顶元素出栈
  • top 的时候,不对 最小栈 做任何操作
  • getMin() 的时候,获取 最小栈 栈顶元素的值

利用最小栈,常数时间复杂度找到最小值

var MinStack = function () {
  this.stack = [] // 数据栈
  this.minStack = [] // 最小栈
}

/**
 * @param {number} val
 * @return {void}
 */
MinStack.prototype.push = function (val) {
  this.stack.push(val) // 数据栈正常push
  // 如果最小栈为空 || 新元素比最小栈栈顶元素小,往最小栈里push,记录最小值
  if (!this.minStack.length || val <= this.minStack[this.minStack.length - 1]) {
    this.minStack.push(val)
  }
}

/**
 * @return {void}
 */
MinStack.prototype.pop = function () {
  // 最小栈也需要出栈
  if (
    this.stack[this.stack.length - 1] ===
    this.minStack[this.minStack.length - 1]
  ) {
    this.minStack.pop()
  }
  this.stack.pop() // 题目没要求要return
}

/**
 * @return {number}
 */
MinStack.prototype.top = function () {
  return this.stack[this.stack.length - 1]
}

/**
 * @return {number}
 */
MinStack.prototype.getMin = function () {
  return this.minStack[this.minStack.length - 1]
}

/**
 * Your MinStack object will be instantiated and called as such:
 * var obj = new MinStack()
 * obj.push(val)
 * obj.pop()
 * var param_3 = obj.top()
 * var param_4 = obj.getMin()
 */

总结

  • 辅助栈 维护一个 最小栈,可以在 O(1) 时间返回 最小值

(Medium) —— 全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

 

示例 1:

输入: nums = [1,2,3]
输出: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:

输入: nums = [0,1]
输出: [[0,1],[1,0]]

示例 3:

输入: nums = [1]
输出: [[1]]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 所有可能全排列
  • 任意顺序

  全排列 是指将所有元素按照一定的顺序排列起来,形成一个队列。

  这种 “穷尽” 类型的题,再加上 排列 这个重复行为,我们应该要联想到 DFS(深度优先遍历),原理是 递归,我们用 递归 试试能不能解决这道题 。

  我们以最简单的情况进行排列(nums = [1, 2]),填充已经确定的两个 “坑位”

image.png

  回溯 算法类似于一个枚举的搜索尝试过程,它在搜索过程中寻找问题的解,当发现某一条路径不满足条件时,就会“回溯”返回,尝试别的路径。

  剪枝 是指 丢掉不合符要求或者重复的答案,通常用 if 来实现。 回溯剪枝寻找全排列答案

  对于 全排列 问题,我们要做的就是在 叶子结点 也就是路径的末尾收获我们的结果。

递归回溯收获叶子结点

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function(nums) {
  const len = nums.length
  const cur = [] // 每条路径的排列
  const res = [] // 最终答案
  const visited = {} // 哈希表,用于剪枝
  // 用索引表示坑位
  function dfs(idx) {
    if (idx === len) {
      res.push(cur.slice()) // 当前处于路径的结尾,这时候保存一下当前路径的数据情况
      return // 返回上个函数调用栈,进行cur和visited的恢复操作
    }
    // 每个坑位所有可能情况,用索引方便操作
    for (let i = 0; i < len; i++) {
      // 历史坑位没有用过,nums[i]就可以用
      if (!visited[nums[i]]) {
        visited[nums[i]] = 1 // 哈希表记录已经用过的元素,其他坑位就不要用了
        cur.push(nums[i])
        dfs(idx + 1) // 递归,路径往前走,寻找下一个坑位
        cur.pop()
        visited[nums[i]] = 0
      }
      // 剪枝,不去进行任何操作,相当于忽略
    }
  }
  dfs(0)
  return res
};

总结

  • 穷举操作考虑 DFS,借助 树形思维 理解 DFS, 每一个 ”坑“ 就对应 树形的某一层

(Medium) —— 子集

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。  

示例 1:

输入: nums = [1,2,3]
输出: [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

输入: nums = [0]
输出: [[],[0]]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 子集
  • 不能重复

  这道题一看就知道可以用 递归回溯 的思想一试,我们来分析一下,先画出取值的图:

image.png

  也是一个树形结构,和 递归 很匹配,也牵扯到了 回溯。和全排列问题不同,收获结果的时机不是在叶子结点,而是在每个结点,也就是每个递归式(这里每一层也是代表每个索引)。

  有个关键的地方,就是图中指出的 只从当前索引往后面取,意思是在第二层的时候就不管数组第一个元素了,第一个元素取不取那是你第一层的事,我第二层不需要操心,我只管第二层的取不取,这样可以避免出现重复的结果。实现的思路就是 for 循环从 当前索引 开始遍历。

递归回溯收获每个结点

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsets = function(nums) {
  const len = nums.length
  const res = [] // 结果数组
  const cur = [] // 每个结点的结果
  // 索引,代表树的每一层
  function dfs(idx) {
    res.push(cur.slice())
    for (let i = idx; i < len; i++) {
      cur.push(nums[i])
      dfs(i + 1) // 易错点,idx代表的是每一层,而i+1代表从当前元素往后
      cur.pop() // 回溯,去掉现有这一层的痕迹
    }
  }
  dfs(0)
  return res
};

总结

  • 递归回溯 的另一种类型,收割每个结点的数据。需要在每次递归都进行收集
  • 如果想要避免重复,利用好遍历的 索引

(Medium) —— 组合

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

 

示例 1:

输入: n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

示例 2:

输入: n = 1, k = 1
输出: [[1]]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 范围、k个数
  • 组合

  这道题也是排列问题,不过是局部排列,那么就要规划好怎么 “剪枝”

  收获结果的时机我们见过 叶子结点每个树节点,而这道题是 元素数量k 个。

  为了防止拿到重复的结果,我们要利用好索引,从索引开始遍历。 递归回溯,终止条件是元素数量

/**
 * @param {number} n
 * @param {number} k
 * @return {number[][]}
 */
var combine = function(n, k) {
  const res = [] // 结果数组
  const cur = [] // 当前结果
  function dfs(startNum) {
    if (cur.length === k) {
      res.push(cur.slice())
      return
    }
    // 1开始,就要 <= n了,注意这个=号啊!
    for (let i = startNum; i <= n; i++) {
      cur.push(i)
      dfs(i + 1 )
      cur.pop() // 回溯
    }
  }
  dfs(1) // 从1开始,没有1,我们自己提供1
};

总结

  • 递归回溯 的另一种类型,收割固定数量的元素。需要根据 length 写好终止条件
  • 如果想要避免重复,利用好遍历的 索引

(Hard) —— 滑动窗口最大值

给你一个整数数组 nums,有一个大小为 k **的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值

示例 1:

输入: nums = [1,3,-1,-3,5,3,6,7], k = 3
输出: [3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7      5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

示例 2:

输入: nums = [1], k = 1
输出: [1]

分析

  首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。

  这道题目的关键词是:

  • 滑动窗口
  • 区间最值

  使用 双指针 可以模拟一个滑动窗口。对于这道题,当指针确定某个窗口时,遍历窗口中的元素就可以求出最值。

双指针模拟滑动窗口

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function (nums, k) {
  // 初始化左右指针
  let left = 0
  let right = k - 1
  const len = nums.length
  const res = [] // 初始化结果数组
  while (right < len) {
    let max = nums[left]
    // 遍历窗口元素,求出最值
    for (let i = left; i <= right; i++) {
      if (nums[i] > max) {
        max = nums[i]
      }
    }
    // 将最大值推入结果数组
    res.push(max)
    // 移动滑动窗口
    left++
    right++
  }
  return res
}

  这种解法看上去没有问题,但在提交的时候有时候能通过,有时候又会超时,我们需要思考优化的方案。


  双指针 法的问题就是会每次都会遍历一遍滑动窗口的值,对于 区间最值 问题,最应该先想到的思路应该是 单调栈/队列,通过控制元素的出入,达到一次遍历求出多个区间的最值的效果。

  这道题可以用 双端队列 来做。什么是 双端队列 ?双端队列就是允许在队列的两端进行插入和删除的队列,借用数组来模拟队列的话,可以支持的 api 就是 pushpopunshiftshift 他都支持,灵活性很高。

  具体的思路是这样的,我们维护一个 单调递减双端队列,这个队列有以下特点:

  • 入队时,从队尾入队,确保新入队的元素比入队前队尾的元素小,队首的元素始终是队列中最大的,方便取出
  • 队列的元素数量不超过 k
  • 队列的元素顺序和数组的元素顺序是一致的,当滑动窗口移动时,要及时移除滑动窗口外的元素,这个元素如果在队列中存在,一定会在队首,要及时的将其出队

  除此之外,有一种特殊情况,就是第一个滑动窗口还没确定好,这时候不能将队首元素推入结果数组,在这之后,滑动窗口开始移动,每移动一次,都将队首元素加入结果数组。对应的遍历方式我们选用 for 循环,循环结束条件是 i < nums.lengthfor 循环可以分为 2 个阶段:

  • 第一个阶段:第一个滑动窗口还没确定,i 还没到 k - 1
  • 第二个阶段:第一个滑动窗口确定,i = k - 1,确定了第一个最值,之后每移动一次,确定一个最值,直至 i = nums.length - 1 结束循环

双端队列滑动窗口求最值

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function (nums, k) {
  const len = nums.length
  const deque = [] // 初始化双端队列
  const res = [] // 初始化结果数组
  for (let i = 0; i < len; i++) {
    // 如果队尾元素较小,将其出队
    while (deque.length && nums[i] > nums[deque[deque.length - 1]]) {
      deque.pop()
    }
    deque.push(i) // 放索引,方便用
    // 及时的将窗口左边的元素出队
    while (i - k >= deque[0]) {
      deque.shift()
    }
    // 第一个窗口形成后,才往结果数组里推
    if (i >= k - 1) {
      res.push(nums[deque[0]])
    }
  }
  return res
}

总结

  • 滑动窗口最值 问题,使用 双端队列