LeetCode Hot 100 前端解法(仅包含简单 + 中等)

1,057 阅读1小时+

LeetCode Hot 100 前端解法(仅包含简单 + 中等)

1. 两数之和

分类:数组 | 哈希表

原文链接

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

var twoSum = function (nums, target) {
  const map = new Map();
  let res = [];
  nums.forEach((num, index) => {
    const otherNum = target - num;
    if (map.has(otherNum)) {
      res = [map.get(otherNum), index];
    } else {
      map.set(num, index);
    }
  });
  return res;
};

解题思路: HashMap ,遍历一遍列表,存在Map中。

2. 两数相加

分类:递归 | 链表 | 数学

原文链接

给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照 逆序 的方式存储的,并且每个节点只能存储 一位 数字。

请你将两个数相加,并以相同形式返回一个表示和的链表。

var addTwoNumbers = function(l1, l2) {
  const dummyHead = new ListNode();
  let temp = dummyHead;
  let andOne = 0;
  // 开始遍历l1、l2 结束的条件(l1和l2都遍历完了,还有一种情况是l1+l2 >= 10的情况,还需要添加一个)
  while(l1 || l2 || andOne) {
    const number1 = l1 ? l1.val : 0;
    const number2 = l2 ? l2.val : 0;
    const num = (number1 + number2 + andOne) % 10; // 逢10进1
    andOne = number1 + number2 + andOne >= 10 ? 1 : 0; // 这里决定要不要进一(要把上一次进位的值加上)
    const newNode = new ListNode(num); // 要插入的新节点
    temp.next = newNode; // 将新节点挂在dummyHead节点上(temp是用来移动dummyHead节点的)
    temp = temp.next; // 移动temp节点为下个节点;
    l1 = l1 && l1.next;
    l2 = l2 && l2.next;
  }
  return dummyHead.next; // 将dummyHead节点的next返回
}

解题思路: 链表。把两个链表看成是相同长度的(不存在的位置用0代替),逢十进一。

Tips: 做链表的题目一般来说会设置一个虚拟的头节点dummyHead,最后返回dummyHead.next;并且会用一个temp来作为中间节点来链接新的节点。

3. 无重复字符的最长子串

分类:哈希表 | 字符串 | 滑动窗口

原文链接

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

/**
 * @param {string} s
 * @return {number}
 */
var lengthOfLongestSubstring = function (s) {
  let subStr = "";
  let maxLen = 0;
  for (let i = 0; i < s.length; i++) {
    // 如果存在,则从当前开始重新计算
    if (subStr.includes(s[i])) {
      const index = subStr.indexOf(s[i]);
      subStr = subStr.substring(index + 1) + s[i];
    //   maxLen = Math.max(maxLen, subStr.length);
    } else {
      subStr += s[i]; // 如果没有重复的,则把子串加上当前的字符串
      maxLen = Math.max(maxLen, subStr.length); // 更新maxLen
    }
  }
  return maxLen;
};

解题思路一:遍历字符串,生成子串。遍历字符的时候对子串进行判断:

  • 如果子串中不存在该字符:则加上该字符生成新的子串。并且更新maxLen。
  • 如果子串中该字符已经存在过,舍弃前一个相同字符串之前的所有字符串,继续遍历生成子串。
// 遍历字符串,将出现过字符串的下标记录到 Hash 表中。并且用 left 指针 和 right 指针表示当前子串的范围。
var lengthOfLongestSubstring = function(s) {
  let maxLen = 0;
  let left = 0;
  let strMap = new Map();  // strMap存储的是该字符串最后一次出现的下标
  for(let right = 0; right < s.length; right++) {
    // 如果 Hash 表中存在该字符串 && 并且对应的下标在 left ~ right 的有效范围
    // 认为出现了重复字符串,则将 left 指针移动到重复字符串下标的下一位。
    if (strMap.has(s[right]) && strMap.get(s[right]) >= left) {
      left = strMap.get(s[right]) + 1; // 把慢指针移到前面的重复字符的下一位
      strMap.set(s[right], right);
    } else {
      // 如果 Hash 表中不存在该字符串:则将该字符和下标的对应关系维护到 Hash 表中。
      strMap.set(s[right], right);
      // 正常情况
      maxLen = Math.max(maxLen, right - left + 1)
    }
  }
  return maxLen;
}

解题思路二:快慢指针 + HashMap。遍历字符串,将出现过字符串的下标记录到 Hash 表中。并且用 left 指针 和 right 指针表示当前子串的范围。

  • 如果 Hash 表中不存在该字符串:则将该字符和下标的对应关系维护到 Hash 表中。
  • 如果 Hash 表中存在该字符串,则认为出现了重复字符串,则将 left 指针移动到重复字符串下标的下一位。

**Tips:**strMap存储的是该字符串最后一次出现的下标。

5. 最长回文子串

分类:字符串 | 动态规划

原文链接

给你一个字符串 s,找到 s 中最长的回文子串。

/**
 * @desc 中心扩散法
 */
function longestPalindrome(s) {
  if (s == null || s.length == 0) {
    return "";
  }
  let strLen = s.length;
  let maxLen = 1; // 初始化 - 最大回文字符串的长度
  let maxStartIndex = 0; // 初始化 - 最大回文字符串的起始位置
  for (var cPoint = 0; cPoint < strLen; cPoint++) {
    let len = 1;
    let leftP = cPoint;
    let rightP = cPoint;
    // 回文子串有两种形式: 1. aa这种形式 2.aba, baab这种形式
    // 先向左右两边分别扩散,优先找出aa,bb这种格式,同时更新以当前 cPoint 为中心的最长回文子串的长度
    while (leftP - 1 >= 0 && s.charAt(leftP - 1) === s.charAt(cPoint)) {
      len += 1;
      leftP -= 1;
    }
    while (rightP + 1 < strLen && s.charAt(rightP + 1) === s.charAt(cPoint)) {
      len += 1;
      rightP += 1;
    }
    // 向左右两边同时扩散
    while (
      leftP - 1 >= 0 &&
      rightP + 1 < strLen &&
      s.charAt(leftP - 1) === s.charAt(rightP + 1)
    ) {
      len += 2;
      leftP -= 1;
      rightP += 1;
    }
    // 扩散结束了,更新 maxLen 和 maxStartIndex
    if (len > maxLen) {
      maxLen = len;
      maxStartIndex = leftP;
    }
  }
  return s.substring(maxStartIndex, maxStartIndex + maxLen);
}


解题思路一:中心扩散法。遍历字符串,从每一个位置出发,向两边扩散即可。遇到不是回文的时候结束。需要注意的是:需要考虑:aa、aba这两种形式的回文字符串。

  1. 以当前位置为中心点,先分别向左右两边扩散,优先找出aa,bb这种格式,同时更新以当前 cPoint 为中心的最长回文子串的长度,和左右边界。
  2. 同时向左右两边进行扩散,同时更新以当前 cPoint 为中心的最长回文子串的长度,和左右边界。
  3. 遇到不是回文,扩散结束。更新一下maxLenmaxStartIndex
  4. 根据maxLenmaxStartIndex返回回文字符串
// 动态规划
function longestPalindrome3(str) {
  // 创建一个空的表格
  const dp = Array(str.length).fill(0).map(item => Array(str.length).fill(0));

  // console.log('dp', JSON.parse(JSON.stringify(dp)));
  // 长度为1的时候
  for (let i = 0; i < str.length; i++) {
    dp[i][i] = true;
    result = str.substring(i, i + 1);
  }
  // console.log('dp', JSON.parse(JSON.stringify(dp)));

  // 长度为2的时候
  for(let i = 0; i < dp.length -1; i++) {
    if (str[i] === str[i + 1]) {
      dp[i][i + 1] = true;
      result = str.substring(i, i + 2);
    } else {
      dp[i][i + 1] = false;
    }
  }

解题思路二:动态规划。dp[i][j] 表示:子串 s[i..j] 是否为回文子串,这里子串 s[i..j] 定义为左闭右闭区间,即可以取到 s[i] 和 s[j]。根据头尾字符是否相等,需要分类讨论:dp[i][j] = (s[i] == s[j]) and dp[i + 1][j - 1]

11. 盛最多水的容器

分类:贪心 | 数组 | 双指针

原文链接

给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。 找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。 返回容器可以储存的最大水量。 说明:你不能倾斜容器。

var maxArea = function(height) {
  let maxArea = 0;
  let leftP = 0;
  let rightP = height.length - 1;
  while (leftP < rightP) {
    // 短板效应,计算当前的面积
    const currentArea = (rightP - leftP) * Math.min(height[startIndex], height[endIndex])
    // 每次计算完面积之后,和maxArea比较,更新maxArea
    maxArea = Math.max(currentArea, maxArea);
    // 当移动较大一边的时候,面积一定会减小。移动较小的那一端,面积可能增加
    // (因为面积 = 宽度 * 高度, 而移动中宽度在减小,高度取决于矮的一端)
    height[leftP] > height[rightP] ? rightP-- : leftP++;
  }
  return maxArea;
}


解题思路: 双指针

  1. 容量取决于左右边的短板和相隔的距离。用leftPrightP从两端向中心移动: currentArea = (rightP - leftP) * Math.min(height[leftP], height[rightP])
  2. 如果移动较大的那一边,面积一定变小。如果移动较小的那一边,面积可能变大(因为面积 = 宽度 * 高度, 而移动中宽度在减小,高度又取决于矮的一端,所以你高的一端再怎么长高也无济于事)
  3. 每次移动完之后算一下currentArea,并且更新maxArea。直到leftPrightP重合为止。

15. 三数之和

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

注意:答案中不可以包含重复的三元组。

原文链接

分类:数组 | 双指针

// 思路:先排序,排序后固定一个数 nums[i]nums[i],再使用左右指针指向nums[i]后面的两端,数字分别为 nums[L]和 nums[R],计算三个数的和 sum 判断是否满足为 00,满足则添加进结果集
// 1. 如果nums[i] > 0. 那没什么好说的了,直接退出循环,return res;
// 2.  如果nums[i] === nums[i - 1],则表示重复了,continue,进入下一轮循环
// 3.  如果nums[i] + nums[left] + nums[right] === 0,则res.push([nums[i], nums[left], nums[right]]);
// 4.  如果nums[i] + nums[left] + nums[right] < 0,则 left++
// 5.  如果nums[i] + nums[left] + nums[rigjt] > 0,则 right--
var threeSum = function(nums) {
  let res = [];
  if (nums?.length < 3) {
    return res;
  }
  // 先排序
  nums.sort((a, b) => a - b);
  debugger
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] > 0){
      break;
    }
    // i 重复的情况,则省去遍历
    if (i - 1 >= 0 && nums[i] === nums[i - 1]) {
      continue;
    }
    let left = i + 1;
    let right = nums.length - 1;
    while(left < right) {
      const count = nums[i] + nums[left] + nums[right];
      if (count === 0) {
        res.push([nums[i], nums[left], nums[right]]);
        // 重点: push之后,left++, right--,但是这个时候left++ || right-- 不能和这一次的值相同
        while(left < right && nums[left] === nums[left + 1]) {
          left += 1;
        } 
        while(left < right && nums[right] === nums[right - 1]) {
          right -= 1;
        } 
        left++;
        right--;
      } else if (count < 0) {
        left += 1;
      } else if (count > 0) {
        right -= 1;
      }
    }
  }
  return res;
}

解题思路:排序 + 双指针

先排序,排序后固定一个数 nums[i],再使用左右指针指向nums[i]后面的两端,数字分别为 nums[L]和 nums[R],计算三个数的和 sum 判断是否满足为 00,满足则添加进结果集

  1. 如果nums[i] > 0. 那没什么好说的了,直接退出循环,return res;

  2. 如果nums[i] === nums[i - 1],则表示重复了,continue,进入下一轮循环

  3. 如果count === 0,则res.push([nums[i], nums[left], nums[right]]);

  4. 如果count < 0,则 left++;

  5. 如果count > 0,则 right--;

17. 电话号码的字母组合

分类:哈希表 | 字符串 | 回溯

原文链接

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

// 深度优先算法 
const letterCombinations3 = function (digits) {
  if (digits.length === 0) {
    return [];
  }
  const map = {
    2: "abc",
    3: "def",
    4: "ghi",
    5: "jkl",
    6: "mno",
    7: "pqrs",
    8: "tuv",
    9: "wxyz",
  };

  const res = [];
  // 1. 要遍历的是什么? digits字符串
  // 2. 在进行dfs的目的是什么? 为了收集str(初始值为'')
  // 3. 遍历中在改变的是什么? digits的下标
  // 4. 停止的条件是什么? 长度为 digits 的长度时可以停止。
  function dfs(str, index) {
    if (str.length === digits.length) {
      res.push(str);
      return;
    }
    const letters = map[digits[index]];
    for (letter of letters) {
      dfs(str + letter, index + 1);
    }
  }
  dfs("", 0);
  return res;
};

解题思路:深度优先算法:遍历所有的可能性。在遍历的过程中,思考4个问题:要遍历的是什么? 在进行dfs的目的是什么?遍历中在改变的是什么?停止的条件是什么?

19. 删除链表的倒数第 N 个结点

分类:链表 | 双指针

原文链接

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

/**
 * @param {ListNode} head
 * @param {number} n
 * @return {ListNode}
 */
const removeNthFromEnd = function(head, n) {
  if (!head.next) {
    return head.next;
  }

  // 首先,有两枚指针,快指针和慢指针
  let dummyHead = new ListNode(0, head);
  let fast = slow = ret;
  // 首先,快指针先走n步,然后快慢指针同时走,直到快指针走到末尾(由于快指针一直比慢指针快n,此时慢指针就是倒数第n个,即要删除的那个位置)
  while(n--) {
    fast = fast.next;
  }
  if(!fast) return ret.next;
  // 然后 fast和slow同时走
  while(fast.next) {
    fast = fast.next;
    // 如果fast的下一次是最后一个元素了 slow就不走了
    slow = slow.next;
  }
  // 这个时候,fast已经走到末尾了,此时slow的下一个元素就是要删除的元素。将其删除
  slow.next = slow.next.next;
  return dummyHead.next;
}

解题思路:快慢指针。我们希望的是:找到倒数第n个的节点,并且把这个节点删除,并且同时还需要记录头指针。试想一下,如果是单指针的话,遍历一次肯定是不够的,因为我永远不知道我遍历的是倒数第几个,所以可以换一个思路,设置一个间隔为n的双指针,当快指针指到最后一个时,慢指针就是倒数n个。

  1. 首先快指针先走n步
  2. 慢指针和快指针同时走,直到快指针走到末尾
  3. 那么此时的慢指针的下一个节点就是要删除的节点: slow.next = slow.next.next

Tips: 做链表删除的题目,推荐设置一个虚拟的头节点,因为我们有可能会将头结点删除。设置一个虚拟头结点就可以不需要考虑删除头结点的情况。

20. 有效的括号

分类:栈 | 字符串

原文链接

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

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。
var isValid = function(s) {
   var str = s;
   for (let i = 0; i < s.length / 2; i++ ) {
       str = str.replace("()", "").replace("[]", "").replace("{}", "");
   }
   return str === "";
};

解题思路一:通过字符串的replace API 对有效的'{}','[]','()'替换成"",比较暴力,但是比较简单易懂

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function (s) {
  const leftArr = ["(", "{", "["];
  const rightArr = [")", "}", "]"];
  const tagMap = {
    "(": ")",
    "{": "}",
    "[": "]",
  };
  const stack = [];
  for (let i = 0; i < s.length; i++) {
    if (leftArr.includes(s[i])) {
      stack.push(s[i]);
    } else if (rightArr.includes(s[i])) {
      // 如果是属于右括号大类的,则弹出栈顶的元素。并且检查是否是能相互抵消的
      if (!(s[i] === tagMap[stack.pop()])) {
        return false;
      }
    }
  }
  // 遍历完成,如果是两两相对的话,则栈中的元素为空
  return stack.length === 0;
};

解题思路二:栈!利用栈的对称性,对字符串进行遍历,碰到'(','{','['入栈,当栈不为空时候,字符串的下一个必须和将要弹出的字符串配对才行。如果有一个不符合,终止遍历,直接返回false。

21. 合并两个有序链表

分类: 递归 | 链表

原文链接

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

var mergeTwoLists = function (l1, l2) {
  const dummyHead = new ListNode(-1);

  let prev = dummyHead;
  while (l1 != null && l2 != null) {
    if (l1.val <= l2.val) {
      prev.next = l1;
      l1 = l1.next;
    } else {
      prev.next = l2;
      l2 = l2.next;
    }
    prev = prev.next;
  }

  // 合并后 l1 和 l2 最多只有一个还未被合并完,我们直接将链表末尾指向未合并完的链表即可
  prev.next = l1 === null ? l2 : l1;

  return dummyHead.next;
};

解题思路:合并两个链表。同时遍历l1,l2,比较l1.val和l2.val的大小。如果其中一条遍历完了,就直接把剩下的那条接在新链表的后面。

22. 括号生成

分类:字符串 | 动态规划 | 回溯

原文链接

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

var generateParenthesis = function(n) {
  const res = [];
  // 1. 第一个关键点: '所有可能' 确定用递归的方式实现。
  const dfs  = function(lRemain , rRemain, str) {
    // 2. 第二个关键点: 递归结束的条件:lRemin,rRemind的长度都为0时
    if (lRemain === 0 && rRemain === 0) {
      res.push(str);
      return;
    }
    // 3. 第三个关键点: 有效的字符串必须是 左括号优先进入。且左括号的个数永远小于等于右括号
    // 选择左括号的前提条件:只要左括号还有的剩,可以直接选择左括号
    if (lRemain > 0) {
      dfs(lRemain - 1, rRemain, str + '(');
    }
    // 选择右括号的前提条件: 剩余的右括号要大于左括号时,才可以让右括号进入,才能保证有效性
    if (rRemain > lRemain) {
      dfs(lRemain, rRemain - 1, str + ')');
    }
  }
  dfs(n , n, '');
  return res;
}

解题思路:回溯算法。提取关键字:所有可能(一般使用递归的方式实现)

  1. 我们每次递归,要么选择左括号、要么选择右括号,左括号 | 右括号的数量在减少,生成的字符串长度在增加。
  2. 为了保证生成括号的有效性,选择左括号和右括号是有条件的:
    • 选择左括号的前提条件:只要左括号还有的剩,可以直接选择左括号
    • 选择右括号的前提条件: 剩余的右括号要大于左括号时,才可以让右括号进入,才能保证有效性
  3. 结束递归的条件:当lRemin,rRemind的长度都为0时

33. 搜索旋转排序数组

分类:数组 | 二分查找

原文链接

整数数组 nums 按升序排列,数组中的值 互不相同 。 在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。 给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。 你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。

var search = function (nums, target) {
  // 时间复杂度:O(logn)
  // 空间复杂度:O(1)
  // [6,7,8,1,2,3,4,5]
  if (nums.length === 1) {
    return nums[0] === target ? 0 : -1;
  }
  let start = 0;
  let end = nums.length - 1;
  while (start < end) {
    // 取 mid
    const mid = parseInt((left + right) / 2);

    // 返回结果
    if (nums[mid] === target) {
      return mid;
    }
    // 边界场景特殊处理 直接返回
    if (nums[start] === target) {
      return start;
    }
    // 边界场景特殊处理 直接返回
    if (nums[end] == target) {
      return end;
    }
    // 假设比左侧第一个还要小,那么说明mid的右侧是 有序递增的
    if (nums[mid] < nums[start]) {
      // 如果 target 就在右边区间内,那么下一个二分的区间就为[mid + 1, end]
      if (nums[mid] < target && nums[end] > target) {
        start = mid + 1;
      } else {
        // 假设右边最大的都比target小,那么就在左边找
        end = mid - 1;
      }
    // 说明 mid 的左侧是有序递增的
    } else {
      // 如果 target 就在左边区间内,那么下一个二分的区间就为[start, mid - 1]
      if (nums[start] < target && nums[mid] > target) {
        end = mid - 1;
      } else {
        // 假设不在左边区间内,那么就找右边区间
        start = mid + 1;
      }
    }
  }
  return -1;
};

解题思路:二分查找法。这题首先,O(logn),我们首先想到二分查找法。

  1. 二分查找法的前提是,必须保证有序,我们通过观察,可以发现,翻转后的数组,可以看成是两个有序数组。
  2. 假设采用二分法,用mid将数组拆成两半的时候,肯定有一半是有序的,有一半是无序的(可以想象一下)
  3. 判断target的左侧和右侧哪边是递增的,来进一步判断target是落在左右两边哪个数组内
  4. 最后可以想象一下,当缩减的数组长度为1的时候还没有找到target, 那么说明target不存在,直接返回-1

Tips:这题本质上还是二分查找法,本质上是在不断缩小搜索的范围,知道缩小到可以直接在leftrightmid中找到target为止。

34. 在排序数组中查找元素的第一个和最后一个位置

分类:数组 | 二分查找

原文链接

给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。 如果数组中不存在目标值 target,返回 [-1, -1]。 你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。

var searchRange = function(nums, target) {
  // 先写个二分法
  var left = 0;
  var right = nums.length - 1;
  while(left < right) {
    var mid = Math.floor((left + right) / 2);
    if (nums[mid] === target) {
      // 先找到一个target,从target的左右两边开始查找
      left = mid - 1;
      right = mid + 1;
      while(left >= 0 && nums[left] === target) {
        left--;
      }
      while(right <= nums.length - 1 && nums[right] === target) {
        right++;
      }
      left++;
      right--;
      return [left, right];
    }
    if (nums[mid] > target) {
      right = mid - 1;
    } else if (nums[mid] < target) {
      left = mid + 1;
    } 
  }
  if (left === right && nums[left] === target) {
    return [left, right];
  } else {
    return [-1, -1]
  }
};

解题思路: 二分查找 + 中心扩散法。由于题目中提到nums是非递减的、且需要采用 O(log n) 的算法,我们首先联想到二分查找(快速排序也是基于二分法)。

具体做法

  1. 用left,right分别指向数组的两端。并找到数组的中点,判断和target的关系:
    • 如果nums[mid] === target,则以此为中心,向左右两边进行扩散,直到 !== target 为止,返回左右边界。
    • 如果nums[mid] > target,则target一定在num[left] ~ nums[mid]中间,right - 1。重复1的过程。
    • 如果nums[mid] < target,则target一定在num[mid] ~ nums[right]中间,left - 1。重复1的过程。
  2. 直到right <= left时退出循环,返回[-1, -1]

39. 组合总和

分类:数组 | 回溯

原文链接

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。 candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。 对于给定的输入,保证和为 target 的不同组合数少于 150 个。

const combinationSum = (candidates, target) => {
  const res = [];
  const dfs = (start, temp, sum) => { // start是当前选择的起点索引 temp是当前的集合 sum是当前求和
    if (sum >= target) {
      if (sum == target) {
        res.push(temp.slice()); // temp的拷贝 加入解集
      }
      return;   // 结束当前递归
    }
    for (let i = start; i < candidates.length; i++) { // 枚举当前可选的数,从start开始
      temp.push(candidates[i]);          // 选这个数
      console.log('temp', temp);
      dfs(i, temp, sum + candidates[i]); // 基于此继续选择,传i,下一次就不会选到i左边的数
      temp.pop();   // 撤销选择,回到选择candidates[i]之前的状态,继续尝试选同层右边的数
    }
  };
  dfs(0, [], 0); // 最开始可选的数是从第0项开始的,传入一个空集合,sum也为0
  return res;
};

解题思路: 不同组合,递归。确定大致方向。接下来的关键点是回溯,在进行下一个选择的时候,需要“撤销选择”回到之前的状态,继续尝试选同层右边的数。

46. 全排列

分类:数组 | 回溯

原文链接

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

var permute = function (nums) {
  const res = [];
  // 已经选中的下标
  const dfs = (used, path) => {
    // 当找到满足条件的时候,return结束递归
    if (path.length === nums.length) {
      res.push([...path]); // 注意 push 的时候需要将arr拷贝一份
      return;
    }

    for (let i = 0; i < nums.length; i++) {
      // 如果当前节点被选择过 continue
      if (used[i]) {
        continue;
      } else {
        // 往path里 push
        path.push(nums[i]);
        // 标记一下已经使用过 方便之后的 剪枝
        used[i] = true;
        // 递归操作。
        dfs(used, path);
        // 相当于回溯到上一层,换一个数进行操作 
        path.pop();
        // 回到上一层的时候,这个标记要去掉
        used[i] = false;
      }
    }
  };
  // used用来记录当前下标是否被使用过, path则是被遍历到的数组
  dfs({}, []);
  return res;
};

解题思路:回溯。需要注意的点: 1. 需要维护一个对象用来记录是否被使用过 2. 回溯到上一层时需要弹出数组的最后一个树。

48. 旋转图像

分类:数组 | 数学 | 矩阵

原文链接

给定一个 n × n 的二维矩阵 matrix 表示一个图像。请你将图像顺时针旋转 90 度。 你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。

/**
 * @param {number[][]} matrix
 * @return {void} Do not return anything, modify matrix in-place instead.
 */
var rotate = function(matrix) {
  const n = matrix.length;
  for (let row = 0; row < Math.floor(n / 2); row++) {
    for (let col = 0; col < Math.floor((n + 1) / 2); col++) {
      const temp  = matrix[row][col] // 需要引入一个临时变量来中转一下
      matrix[row][col] = matrix[n - 1 - col][row];
      matrix[n - 1 - col][row] = matrix[n - 1 - row][n - 1 - col];
      matrix[n - 1 - row][n - 1 - col] = matrix[col][n - 1 - row];
      matrix[col][n - 1 - row] = temp;
    }
  }
};

解题思路:找规律

  1. 首先,对于矩阵中第 i行的第 j个元素,在旋转后,它出现在倒数第 i列的第 j行个位置。推导出:

Matrix[j][n - 1 - i] = Matrix[i][j]

  1. 由于题目要求原地旋转,所以需要一次性同时交换四个位置:按照1中的公式,找出这四个位置的旋转关系:

Matrix[j][n - 1 - i] = Matrix[i][j]

Matrix[n - 1 - i][n - 1 - j] = Matrix[j][n - 1 - i]

Matrix[n - 1 - j][i] = Matrix[n - 1 - i][n - 1 - j]

Matrix[i][j] = Matrix[n - 1 - j][i]

  1. 确定旋转的次数:
    • 当n为偶数时:需要旋转 n^2 / 4 = n / 2 * n / 2次。即:双层循环,每层次数为 n / 2 次
    • 当n为奇数时:需要旋转 (n^2 − 1) / 4 = ((n−1)/2) * ((n+1)/2)次。即:双层循环,第一层次数为n - 1 / 2次,第二层次数为 n + 1 / 2次(其实也可以反过来)。
    • 综合以上两种情况:第一层循环的次数为 Math.floor(n / 2) 次,第二层循环的次数为Math.floor((n + 1) / 2)次(其实也可以反过来)

49. 字母异位词分组

分类:数组 | 哈希表 | 字符串 | 排序

原文链接

你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。

字母异位词 是由重新排列源单词的字母得到的一个新单词,所有源单词中的字母通常恰好只用一次。

var groupAnagrams = function (strs) {
  const mapObj = {};
  strs.forEach((item) => {
    // 解题重点步骤: 将 item 排序,将排序结果作为key
    // (字母异位词排序后的字符串是相同的)
    const key = Array.from(item).sort().join('')
    if (mapObj[key]) {
      mapObj[key].push(item);
    } else {
      mapObj[key] = [item];
    }
  });
  return Object.values(mapObj);
};

解题思路: HashMap。在遍历的时候,将字符串先排序一下作为key,然后遍历strs收集字符串。最后输出。

var groupAnagrams = function(strs) {
  const map = new Object();
  for (let s of strs) {
      const count = new Array(26).fill(0);
      for (let c of s) {
          // 这里巧妙的运用了和字符 a 的距离来构建 key (节省了排序的时间)
          count[c.charCodeAt() - 'a'.charCodeAt()]++;
      }
      // '1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0' 作为key
      const key = count.toString();
      map[key] ? map[key].push(s) : map[key] = [s];
  }
  return Object.values(map);
};

解题思路: HashMap。在遍历的时候,用每个字符串出现的次数组成的数组作为key。(巧妙运用和字符 a 的距离来构建key)

53. 最大子数组和

分类:数组 | 分治 | 动态规划

原文链接

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。 子数组 是数组中的一个连续部分。

/**
 * @param {number[]} nums
 * @return {number}
 */
var maxSubArray = function(nums) {
  // 用动态规划的模板先做一遍。
  // 1. 定义dp[i]:以i结尾的连续数组最大和
  const dp = new Array(nums.length).fill(false);
  // 3. 定义初始化状态
  dp[0] = nums[0];
  let res = dp[0] ;
  // 2. 状态转移方程:dp[i] = Math.max(nums[i], dp[i - 1] + nums[i])
  for (let i = 1; i < nums.length; i++) {
    dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);
    res = Math.max(dp[i], res);
  }
  return res;
};

解题思路:动态规划。动态规划三部曲:

  1. 定义dp[i]:dp[i]指的是以当前下标结尾时,的「连续子数组的最大和」
  2. 定义状态转移方程:dp[i + 1] = Math.max(dp[i] + nums[i + 1], dp[i])
  3. 初始化Base Case:dp[0] = nums[0]

**Tips:**这题dp[i]有点不太明显,很可能第一反应不会想到动态规划。不过这题用动态规划还算是比较简单,所以采用动态规划三部曲这套招式能搞定。

55. 跳跃游戏

分类:贪心 | 数组 | 动态规划

原文链接

给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。 数组中的每个元素代表你在该位置可以跳跃的最大长度。 判断你是否能够到达最后一个下标。

/**
 * @desc 跳跃游戏(贪心) 贪心算法
 * 遍历数组中的每一个位置,实时计算最远可以到达的位置。
 * 如果最大位置大于等于数组长度,则结束,返回true,否则返回false
 */
var canJump = function (nums) {
  const n = nums.length;
  let rightmost = 0;
  for (let i = 0; i < n; i++) {
    // 前提条件,当前的下表是可达到的。
    if (i <= rightmost) {
      // 实时计算最远可以到达的位置
      rightmost = Math.max(rightmost, i + nums[i]);
      // 提前结束
      if (rightmost >= n - 1) {
        return true;
      }
    } else {
      return false;
    }
  }
  return false;
};

解题思路:贪心算法。根据题目的描述,只要存在一个位置 i1,它本身可以到达并且它跳跃的最大长度为 i1 + nums[i1],这个值大于等于i2,即i1 + nums[i1] >= i2,那么位置 i2 也可以到达。

56. 合并区间

分类:数组 | 排序

原文链接

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。

/**
 * @param {number[][]} intervals
 * @return {number[][]}
 */
var merge = function (intervals) {
  const res = [];
  // 先根据左区间进行排序,好处: 1.判断一端就能保证区间有没有重叠 2.只需要确定合并后的右区间
  intervals = intervals.sort((a, b) => a[0] - b[0]);
  // overLappingIntervals 为最新的重叠区间,默认的第一个重叠区间为 intervals[0]
  let overLappingIntervals = intervals[0];

  for (let i = 1; i < intervals.length; i++) {
    let cur = intervals[i];
     // 有重合:    |------|
     //       |------|
    if (overLappingIntervals[1] >= cur[0]) {
      overLappingIntervals[1] = Math.max(cur[1], overLappingIntervals[1]); 
    } else {      
       // 不重合,overLappingIntervals推入res数组 
      res.push(overLappingIntervals);
      overLappingIntervals = cur;  // 更新 overLappingIntervals
    }
  }
  // 重点!! 记得将最后一个重叠区间push
  res.push(overLappingIntervals);
  return res;
};

解题思路: 先排序,再合并

  1. 先将区间集合根据左区间的进行升序排列。原因:
    • 其实用一端就能判断出两个区间有没有重叠
    • 只需要关心合并后的右区间
  2. 将默认的重叠区间设为intervals[0]。然后从intervals[1]开始遍历,如果当前区间的左区间 <= 重叠区间的右区间。说明有重叠。将两个区间进行合并,并且更新重叠区间:overLappingIntervals[1] = max(overLappingIntervals[1], cur[1])
  3. 如果当前区间没和当前的重叠区间发生重叠,那么将重叠区间加入res。并且更新最新的重叠区间为cur
  4. 记得遍历完区间后,需要将最后一个重叠区间加入到res中。

64. 最小路径和

分类:数组 | 动态规划 | 矩阵

原文链接

给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。 说明:每次只能向下或者向右移动一步。

var minPathSum = (grid) => {
  // 思路:其实这也是一个动态规划的题 要到[m,n]的最小路径,就算到 Math.min([m-1, n],[m. n -1])的最小路径
  const columns = grid[0].length; // 列
  const row = grid.length; // 行
  // 自己维护一个二维数组来保存到各个点的最小路径
  const dp = new Array(row)
    .fill(null)
    .map((item) => new Array(columns).fill(0));
  dp[0][0] = grid[0][0];
  // 向下和向右都只有一种走法,先将第一行和第一列填充好。(已知条件)
  for (let i = 1; i < columns; i++) {
    dp[0][i] = dp[0][i - 1] + grid[0][i];
  }

  for (let i = 1; i < row; i++) {
    dp[i][0] = dp[i - 1][0] + grid[i][0];
  }
  // 开始计算到每个点的路径
  for (let i = 1; i < row; i++) {
    for (let j = 1; j < columns; j++) {
      dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
    }
  }
  return dp[row - 1][columns - 1];
};

解题思路:动态规划。解题思路:

由于要到达右下角,肯定是从他的上方dp[m - 2][n - 1]或者左方dp[m - 1][n - 2]走下来的。对于dp[m - 2][n - 1]dp[m - 1][n - 2]也是如此。

  1. 定义dp[i][j]:到达当前点的数字总和最小为多少
  2. 状态转移方程:dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]
  3. 初始化Base Case:dp[0][0] = grid[[0][0] dp[0][x] = dp[0][x - 1] + grid[0][x] dp[x][0] = dp[x - 1][0] + grid[x][0]

70. 爬楼梯

分类:记忆化搜索 | 数学 | 动态规划

原文链接

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
  const dp = new Array(n + 1).fill(false);
  dp[1] = 1;
  dp[2] = 2;
  for (i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
};

解题思路:动态规划。解题思路:

由于爬到n阶楼梯、且每次只能爬1或者2。那么爬到n的时候一定是n-1或者n-2的时候爬上来的。

  1. 定义dp[i]: 爬到i的时候有几种方法
  2. 状态转移方程: dp[i] = dp[i - 1] + dp[i - 2]
  3. 初始化Base Case:dp[1] = 1dp[2] = 2

75. 颜色分类

分类:数组 | 双指针 | 排序

原文链接

给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。

我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。

必须在不使用库的sort函数的情况下解决这个问题。

var sortColors = function(nums) {
  let p1 = 0; // 定义: p1左边的元素全为 0 (用来隔离边界的指针)
  let p2 = nums.length - 1; // 定义: p2右边的元素全为 2 (用来隔离边界的指针)
  let i = 0; // (用来遍历的指针)
  // 停止的条件: i 移动到 p2 的右边时
  while(i <= p2) {
    const number = nums[i];
    if (number === 0) {
      // 为0的时候,将当前下标和nums[p1]下的数换一下,那么nums[p1]下的数就一定为0,所以p1++后,p1的左边都为0
      swap(nums, i, p1);
      p1 += 1;
      i++; // 
    } else if (number === 1) {
      // 为1的时候,啥也不做,看下一个数
      i++; 
    } else if (number === 2) {
      // 为2的时候,将当前下标和nums[p2]下的数换一下,那么nums[p2]下的数就一定为0,所以p2--后,p1的右边都为2
      swap(nums, i, p2);
      p2 -= 1;
      // !但是 这个时候i不能++,因为换过来的数还是看过,必须经过下一轮循环的判断
    }
  }
  return nums;
}

解题思路: 双指针 + 分区

  1. 我们用 leftP、rightP两枚指针来做分区,其中:[0, leftP)的都为0、[leftP, rightP]的都为1、(rightP, end]的都为2。
  2. 再定义一枚用于遍历的current指针,在遍历的过程中会出现一下三种情况:
    • nums[current] === 0:将nums[current]nums[left]交换,left指针和current指针都向前移动一位
    • nums[current] === 1:不做任何处理,仅移动current指针
    • nums[current] === 2:将nums[current]nums[right]交换,仅把current指针向前移动一位。因为从右边界换过来的数是没有判断过的,需要在下一次循环中再判断一次。

78. 子集

分类:位运算 | 数组 | 回溯

原文链接

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。 解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

// @desc 这题从题目来看是一题回溯的题目
/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsets = function (nums) {
  // 0. 确定用回溯算法
  let result = [];
  let path = [];
  // 1. 回溯函数
  function backtracking(startIndex) {
    result.push(path.slice());
    // 2. 结束条件隐藏在这里,startIndex 为 nums.length 的时候
    for (let i = startIndex; i < nums.length; i++) {
      // 重点步骤一(向下走):走过的地方push到 path中。
      path.push(nums[i]);  
      backtracking(i + 1); // 注意从 i + 1开始,元素不重复取
      // 重点步骤二(回溯,向右走):将之前走过的path 回退
      path.pop();
    }
  }
  backtracking(0);
  return result;
};

//              1                 2              3              4
//        /     |     \         /    \           |    
//    2         3       4    3        4          4
//   / \        |            |
// 3    4       4            4
// /
// 4

解题思路: 回溯。首先,从题目开始分析,'可能的子集'。那基本上就是回溯来解题。那么第一步先写一个回溯函数,第二步,找出结束递归的条件。(从树状的注解中,我们分析出startIndex如果为最后一个,那么就不继续递归了)。第三步:单层搜索逻辑。注意往下一个兄弟节点走的时候,要回退一下。

79. 单词搜索

分类:数组 | 回溯 | 矩阵

原文链接

给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。 单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

var exist = function (board, word) {
  const row = board.length;
  const columns = board[0].length;
  let used = new Array(row).fill(false).map(() => new Array(columns).fill(false))
  // 该函数计算从 i,j 这个点开始找能否找到
  const canFind = (i, j, currentWordIndex) => {
    if (currentWordIndex === word.length) {
      return true;
    }

    if (i < 0 || j < 0 || i >= row || j >= columns) {
      return false; // 1.下标越界的场景,不能走出框外 (下标越界的情况要最先判断、提前return。防止return)
    }

    if (used[i][j]) {
      return false // 3. 可能上一步往上走、下一步又往下走了,不行。所以记录一下(不能走走过的点)
    }

    // 在dfs函数中,判断false场景
    if (board[i][j] !== word[currentWordIndex]) {
      return false // 2. 下一步的字符对不上
    }

    
    used[i][j] = true;
    // 上、下、左、右都走走一下,试一试
    const canFindRes = 
       canFind(i + 1, j, currentWordIndex + 1)
    || canFind(i, j + 1, currentWordIndex + 1)
    || canFind(i - 1, j, currentWordIndex + 1)
    || canFind(i, j - 1, currentWordIndex + 1)
    if (canFindRes) {
      return true;
    } else {
      // 重点!!!: 如果上下左右都走不通,这个点行不通,回撤路径
      used[i][j] = false;
      return false;
    }
  }

  for (let i = 0; i < row; i++) {
    for (let j = 0; j < columns; j++) {
      // 先找到 '头'。这个总没错的
      if (board[i][j] === word[0]) {
        // 每到一个点做的事情是一样的。DFS 往下选点,构建路径。
        if (canFind(i, j, 0)) {
          // 一旦找到一个就立马return
          return true;
        }
      }
    }
  }
  return false;
};

console.log('res', exist([["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], 'SEE'));

解题思路: 递归。明确一下思路:

  • 以"SEE"为例,找到改单词,首先必须找到首字母

  • 起点可能不止一个,可能在矩阵中存在多个"S"

  • 从该起点开始寻找剩下的"EE"字符串

  • 寻找剩下的字符串有4个方向:上下左右

  • 逐个尝试每一种选择。基于当前选择,为下一个字符选点,又有上下左右四种选择(有递归那味儿了?)。

具体做法

  1. 首先找到首字母所在的位置,找到递归的起点。
  2. 进行递归寻找下一个字符
  3. 如果当前点是错的,不用往下递归了,返回false。否则继续递归四个方向,为剩下的字符选点。 那么,哪些情况说明这是一个错的点:
    • 当前的点,越出矩阵边界。
    • 之前访问过的点(可能上一步往上走、下一步又往下走了,不行)
    • 字符串对不上的点
  4. 所以,需要一个used二维数组来记录一下已经访问过的点,下次再选择访问这个点就直接返回false

**Tips:**如果求出 canFindRest 为 false,说明基于当前点不能找到剩下的路径,所以当前递归要返回false,还要在used矩阵中把当前点恢复为未访问,让它后续能正常被访问。

94. 二叉树的中序遍历

分类:栈 | 树 | 深度优先搜索 | 二叉树

原文链接

给定一个二叉树的根节点 root ,返回 它的 中序 遍历 。

var inorderTraversal = function (root) {
  let res = [];
  const traversal = (node) => {
    if (!node) {
      return;
    }
    traversal(node.left);
    res.push(node.val);
    traversal(node.right);
  };
  traversal(root);
  return res;
};

解题思路: 递归。写递归代码是有技巧的,递归三部曲:

  1. 结束条件
  2. 单层逻辑
  3. 传递参数

Tips:二叉树的遍历:所谓前序,中序,后续遍历命名的由来是我们访问二叉树根节点的顺序。前序遍历就是优先访问根节点,中序遍历是第二个访问根节点,后续遍历就是访问完左右节点之后,最后访问根节点。

96. 不同的二叉搜索树

分类:树 | 二叉搜索树 | 数学 | 动态规划 | 二叉树

原文链接

给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。

/**
 * @param {number} n
 * @return {number}
 */
var numTrees = function(n) {
  // dp[i]=∑dp[j]∗dp[i−j−1],0<=j<=i−1 
  const dp = new Array(n + 1).fill(false);
  dp[0] = 1;
  dp[1] = 1;
  // 从2开始,计算 dp[i]。
  for (let i = 2; i < n + 1; i++) {
    let count = 0;
    for (let j = 0; j < i; j++)  {
      // j 为左边分配的个数,那么右边分配的个数为 i - j - 1(还有一个根元素)
      count += dp[j] * dp[i - j - 1];
    }
    dp[i] = count;
  }
  return dp[n];
};

解题思路: 动态规划。 解题思路

  • 我们经过分析,假设 1~n中的k作为根节点。则小于k的会作为左节点。大于k的会作为右节点(二叉搜索树的特点)。
  • 左节点假设有 n 种排列方法。 右节点有 m 种排列方法。 则总共有 m * n 中排列方法。
  • 那么拿左节点距离,假设左节点有count个数,有几种二叉搜索树的排列方法:除去根节点,剩 i-1 个节点构建左、右子树,左子树分配 0 个,则右子树分配到 i−1 个……以此类推。
  • 左子树用掉 j 个,则右子树用掉 i-j-1 个,能构建出 dp[j] * dp[i-j-1] 种不同的二叉搜索树。
  • 所以:用连续的n个数所构建BST的个数为:dp[i]=∑dp[j]∗dp[i−j−1],0<=j<=i−1

动态规划三部曲:

  1. 定义dp[i]:用连续的i个数,搜构建的二叉搜索树个数
  2. 状态转移方程:dp[i]=∑dp[j]∗dp[i−j−1],0<=j<=i−1(双重for循环)
  3. 初始化Base Case:dp[0] = 1、dp[1] = 1

98. 验证二叉搜索树

分类:树 | 深度优先搜索 | 二叉搜索树 | 二叉树

原文链接

给你一个二叉树的根节点 root ,判断其是否是一个有效的二叉搜索树。 有效 二叉搜索树定义如下:

节点的左子树只包含 小于 当前节点的数。 节点的右子树只包含 大于 当前节点的数。 所有左子树和右子树自身必须也是二叉搜索树。

var isValidBST = function(root) {

  const helper = (root, lower, upper) => {
    // 递归三部曲: 2.确定递归结束的条件: 节点遍历完了
    if (root === null) {
        return true;
    }
    // 递归三部曲: 3.每一层递归要做的事情,左右节点和当前节点进行对比
    if (root.val <= lower || root.val >= upper) {
        return false;
    }
    return helper(root.left, lower, root.val) && helper(root.right, root.val, upper);
  }
  // 递归三部曲: 1.确定递归函数的入参和返回值。根节点,左节点,右节点
  return helper(root, -Infinity, Infinity);
};

解题思路一: 递归。验证二叉搜索树其实就是递归验证左右节点和当前节点进行对比。

var isValidBST = function (root) {
  // 验证二叉搜索树,中序遍历判断是否有序就行呢
  let current;
  let flag = true;
  function traversal(node) {
    if (!node) {
      return;
    }
    traversal(node.left);
    // current值为0的时候,也会进这里
    if (current !== 0 && !current) {
      current = node.val;
    } else {
      // 主要等于的情况
      if (node.val <= current) {
        flag = false;
      }
      current = node.val;
    }
    traversal(node.right);
  }
  traversal(root);
  return flag;
};

解题思路二: 树的中序遍历。验证二叉搜索树其实就是中序遍历二叉树,判断是否有序就行。

101. 对称二叉树

分类:树 | 深度优先搜索 | 广度优先搜索 | 二叉树

原文链接

给你一个二叉树的根节点 root , 检查它是否轴对称。

var isSymmetric = function(tree) {
  
  const isMirror = function(leftNode, rightNode) {
    // 递归三部曲: 2.确定递归结束的条件
    if (leftNode === null && rightNode === null) {
      return true
    }
    // 递归三部曲: 3.确定每次递归需要处理的问题
    if (leftNode && rightNode) {
      return leftNode.val === rightNode.val 
      && isMirror(leftNode.right, rightNode.left) 
      && isMirror(leftNode.left, rightNode.right);
    }
    if ((leftNode && !rightNode) || (!leftNode && rightNode)) {
      return false;
    }
  }
  // 递归三部曲: 1.确定递归函数的参数和返回值
  return isMirror(tree.left, tree.right)
}

解题思路: 递归。一棵树是对称的,同层节点的左子树和另一节点的右子树是相同的。 现在的问题就是需要让左右子树同时进行遍历。。

102. 二叉树的层序遍历

分类:树 | 广度优先搜索 | 二叉树

原文链接

给你二叉树的根节点 root ,返回其节点值的 层序遍历 。 (即逐层地,从左到右访问所有节点)。

var levelOrder = function (root) {
  if (!root) {
    return []
  }
  const stack = []; // 用来记录层序遍历过程中的每一层
  const res = []; // 结果
  stack.push(root);
  while(stack.length) {
    const length = stack.length; // 提前记录一下length(因为之后还会往stack中push)
    const stagedArr = [];
    // 遍历当前层的所有子元素
    for (let i = 0; i < length; i++) {
      const currentNode = stack.shift();
      stagedArr.push(currentNode.val);
      if (currentNode.left) {
        stack.push(currentNode.left);
      }
      if (currentNode.right) {
        stack.push(currentNode.right);
      }
    }
    res.push(stagedArr); // 存储当前层遍历出来的数据
  }
  return res;
}

解题思路: 广度优先遍历。利用stack来记录层序遍历过程中的每一层。

104. 二叉树的最大深度

分类:树 | 深度优先搜索 | 广度优先搜索 | 二叉树

原文链接

给定一个二叉树,找出其最大深度。 二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。 说明: 叶子节点是指没有子节点的节点。 示例: 给定二叉树 [3,9,20,null,null,15,7], 3 / \

9 20 /
15 7 返回它的最大深度 3 。

var levelOrder = function (root) {
  if (!root) {
    return []
  }
  const stack = []; // 用来记录层序遍历过程中的每一层
  const res = []; // 结果
  stack.push(root);
  while(stack.length) {
    const length = stack.length; // 提前记录一下length(因为之后还会往stack中push)
    const stagedArr = [];
    // 遍历当前层的所有子元素
    for (let i = 0; i < length; i++) {
      const currentNode = stack.shift();
      stagedArr.push(currentNode.val);
      if (currentNode.left) {
        stack.push(currentNode.left);
      }
      if (currentNode.right) {
        stack.push(currentNode.right);
      }
    }
    res.push(stagedArr); // 存储当前层遍历出来的数据
  }
  return res;
}

解题思路:递归。将问题分解成子问题:求根节点的最大深度 -> 根节点Max(左节点的深度,右节点的深度) + 1

105. 从前序与中序遍历序列构造二叉树

分类:树 | 数组 | 哈希表 | 分治 | 二叉树

原文链接

给定两个整数数组 preorder 和 inorder ,其中 preorder 是二叉树的先序遍历, inorder 是同一棵树的中序遍历,请构造二叉树并返回其根节点。

var buildTree = function (preorder, inorder) {
  // 思路: 构建一个二叉树三个部分: root、左子树、右子树
  // 左子树、右子树的构建又分为: root、左子树、右子树
  // 那么阶梯的关键问题就在于定位根节点,划分出左右子树,然后递归构建左右子树
  // 定位根节点: 前序遍历中的第一个一定是整棵树的根节点。 左子树的根节点是 当前根节点 + 1。右子树的根节点是 当前根节点 + 左子树的个数


  /***
   * @param1 当前子树在 前序遍历结果preorder 中的起始下标
   * @param2 当前子树在 前序遍历结果preorder 中的结束下标
   * @param3 当前子树在 中序遍历结果inorder  中的起始下标
   * @param4 当前子树在 中序遍历结果inorder  中的结束下标
  */
  const helper = (pStart, pEnd, iStart, iEnd) => {
    if (pStart > pEnd) {
      return null;
    }
    let rootVal = preorder[pStart];    // 根节点的值
    let root = new TreeNode(rootVal);   // 根节点
    let mid = inorder.indexOf(rootVal); // 根节点在inorder的位置 用来
    let leftNum = mid - iStart;        // 左子树的节点数
    root.left = helper(pStart + 1, pStart + leftNum, iStart, mid - 1);  // 构建左节点
    root.right = helper(pStart + leftNum + 1, pEnd, mid + 1, iEnd); // 构建右节点
    return root;
  };
  // 递归三部曲 1.递归函数的参数和返回值 返回root节点。入参为新的前序、中序遍历序列
  return helper(0, preorder.length - 1, 0, inorder.length - 1);
};

解题思路: 递归。思路:

  1. 构建一个二叉树三个部分: root、左子树、右子树;
  2. 左子树、右子树的构建又分为: root、左子树、右子树;
  3. 那么阶梯的关键问题就在于定位根节点,划分出左右子树,然后递归构建左右子树
  4. 定位根节点: 前序遍历中的第一个一定是 整棵树的根节点。 左子树的根节点是 当前根节点 + 1。右子树的根节点是 当前根节点 + 左子树的个数。

114. 二叉树展开为链表

分类:栈 | 树 | 深度优先搜索 | 链表 | 二叉树

原文链接

给你二叉树的根结点 root ,请你将它展开为一个单链表:

展开后的单链表应该同样使用 TreeNode ,其中 right 子指针指向链表中下一个结点,而左子指针始终为 null 。 展开后的单链表应该与二叉树 先序遍历 顺序相同。

var flatten = function (root) {
  // 这是不是本质上就是先序遍历root树,产生一个新的树?
  const newTreeNode = new TreeNode();
  let temp = newTreeNode;
  function traversal(node) {
    if (!node) {
      return;
    }
    temp.right = new TreeNode(node.val);
    temp = temp.right;
    traversal(node.left);
    traversal(node.right);
  }
  
  traversal(root);
  if (root) {
      root.left = newTreeNode.right && newTreeNode.right.left;
      root.right = newTreeNode.right && newTreeNode.right.right;
  }
  return root;
};

解题思路:递归。本质上就是数的先序遍历。不过本题需要在原来的root上进行修改,直接返回一个新的树貌似不行。

121. 买卖股票的最佳时机

分类:数组 | 动态规划

原文链接

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。 你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。 返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
  // 1. dp[i]的定义
  const dp = new Array(prices.length).fill(false);
  // 3. Base Case
  dp[0] = 0;
  let prevMinPrice = prices[0];
  let res = 0;
  for (let i = 1; i < prices.length; i++) {
    // 2. 状态转移方程
    dp[i] = Math.max(dp[i - 1], prices[i] - prevMinPrice);
   
    // 更新 prevMinPrice、res;
    prevMinPrice= Math.min(prevMinPrice, prices[i]);
    res = Math.max(dp[i], res);
  }
  return res;
};

解题思路:动态规划:试想一下我在第i天的最大收益,有两种情况:

  • 我在之前某一天的最低点买入,然后我今天卖掉了,赚的彭满钵满
  • 我今天啥也不干,我今天的最大收益和昨天的最大收益是一样的

具体做法:

  1. 定义dp[i]:在第i天的最大收益
  2. 状态转移方程:dp[i] = Math.max(dp[i], prices[i] - prevMinPrice)
  3. 初始化Base Case:dp[0] = 0

128. 最长连续序列

分类:并查集 | 数组 | 哈希表

原文链接

给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。 请你设计并实现时间复杂度为 O(n) 的算法解决此问题。

var longestConsecutive = function (nums) {
  const numsSet = new Set(nums) // set存放数组的全部数字,去重
  let max = 0;
  for (let i = 0; i < nums.length; i++) {
    let count = 1;
    let cur = nums[i];
    // nums[i]没有左邻居,是序列的起点
    if (!numsSet.has(cur - 1)) {
      // 一直找到不连续为止
      while (numsSet.has(cur + 1)) {
        // cur有右邻居cur+1
        cur++; // 更新cur
        count++;
      }
      max = Math.max(max, count); // cur不再有右邻居,检查count是否最大
    }
  }
  return max;
};

解题思路:利用Set的数据结构

  1. 首先,我们为了降低检索的消耗,将所有内容存到Set中。

  2. 然后找到所有可能连号儿的起点(十分关键)。由于任何一个点都有可能为连号起点,我们排除掉不可能为连号的,也就是 num - 1 存在的(这个比较难想到)。

  3. 从连号儿的起点,一直找下一个连续的号,直到找不到为止

  4. 每次连号儿断掉的时候,更新一下max

136. 只出现一次的数字

分类:位运算 | 数组

原文链接

给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

var singleNumber = function(nums) { 
  var obj = {};
  for(let i = 0; i < nums.length; i++) {
    obj[nums[i]] ? delete obj[nums[i]] : obj[nums[i]] = 1;
  }
  return Object.keys(obj)[0];
}

解题思路一: HashMap。使用hash表,如果当前key不存在,则令obj[key] 为 1。如果存在则delete 当前属性。由于其余每个元素均出现两次,那么最后就剩下了只出现一次的元素。(消消乐!)

// 利用位运算 异或
var singleNumber = function(nums) {
  let res;
  for(let i = 0; i < nums.length; i++) {
      res ^= nums[i];
  }
  return res;
}

解题思路二: 位运算。位运算中的异或运算 XOR,主要因为异或运算有以下几个特点:

  1. 一个数和 0 做 XOR 运算等于本身:a⊕0 = a
  2. 一个数和其本身做 XOR 运算等于 0:a⊕a = 0
  3. XOR 运算满足交换律和结合律:a⊕b⊕a = (a⊕a)⊕b = 0⊕b = b

所以,利用2,3两条特点,异或运算得到的结果就是那个落单的!

139. 单词拆分

分类:字典树 | 记忆化搜索 | 哈希表 | 字符串 | 动态规划

原文链接

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。 注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

/**
 * @param {string} s
 * @param {string[]} wordDict
 * @return {boolean}
 */
var wordBreak = (s, wordDict) => {
  const len = s.length;
  const wordSet = new Set(wordDict);
  const memo = new Array(len);

  const canBreak = (start) => {
    if (start == len) {
      return true;
    }
    // memo用来存储,从当前start开始,能否到达终点
    if (memo[start] !== undefined) return memo[start]; // memo中有,就用memo中的

    for (let i = start + 1; i <= len; i++) {
      const prefix = s.slice(start, i);
      if (wordSet.has(prefix) && canBreak(i)) {
        // 这里什么情况下会进来存一下呢? 当他到达终点的时候
        memo[start] = true; // 当前递归的结果存一下
        return true;
      }
    }
    memo[start] = false; // 当前递归的结果存一下
    return false;
  };
  return canBreak(0);
};

解题思路:**DFS + 记忆化 **。

  1. leetcode是否能break,可以拆分为:l是否是字典中的单词,剩余的子串是否能break。le是否是字段中的单词,剩余的子串是否能break,以此类推。
  2. 用DFS回溯,考察所有的拆分可能,指针从左往右扫描。
  3. 重点:用一个数组,存储计算的结果,数组索引为指针位置,值为计算的结果。下次遇到相同的子问题,直接返回命中的缓存值,就不用调重复的递归。(不加上这个memo,有个case会超时!!)

141. 环形链表

分类:哈希表 | 链表 | 双指针

原文链接

给你一个链表的头节点 head ,判断链表中是否有环。 如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。

/**
 * 快慢指针法
 * 假设操场上有两个人在跑步,A跑一圈要1分钟 B跑一圈要2分钟,那么A终究会把B套圈!!
 */
var hasCycle = function (head) {
  let p1 = head;
  let p2 = head;
  while (p1 && p2 && p2.next) {
    p1 = p1.next; // p1是满指针,每次跑一步
    p2 = p2.next.next; // p2是快指针,每次跑两步
    if (p1 === p2) {
      return true;
    }
  }
  return false;
};

解题思路: 双指针:用快慢两根指针,快的每次走2步,慢的每次走1步。

  1. 如果能遍历完,说明没有环
  2. 如果存在环,则快指针总有把满指针追上的时候,这时候返回true

142. 环形链表 II

分类:哈希表 | 链表 | 双指针

原文链接

给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。 如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。

var detectCycle = function (head) {
  let p1 = head;
  while (p1) {
    if (p1.flag) {
        return p1;
    }
    p1.flag = true;
    p1 = p1.next;
  }
  return null;
};

解题思路: 遍历的时候记录一下: 每次遍历到了都记录一下。下次再遍历到,直接return

其他解题思路:双指针leetcode-cn.com/problems/li…

146. LRU 缓存

分类:设计 | 哈希表 | 链表 | 双向链表

原文链接

请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。 实现 LRUCache 类:

LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存 int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。 void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字

函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

var ListNode = function(key, value) {
  this.key = key;
  this.value = value;
  this.prev = null;
  this.next = null;
}

/**
 * @param {number} capacity
 */
var LRUCache = function(capacity) {
  // 初始化的时候,限制一下最大值
  this.capacity = capacity;
  this.hashTable = {};
  this.count = 0; // 记录一下缓存的个数
  this.listHead = new ListNode();
  this.listTail = new ListNode();
  this.listHead.next = this.listTail // 头尾相连
  this.listTail.prev = this.listHead // 头尾相连
};

/** 
 * @param {number} key
 * @return {number}
 */
LRUCache.prototype.get = function(key) {
  let node = this.hashTable[key];
  if (!node) {
    return -1; // 如果没找到,直接返回-1
  }
  // 如果存在的话,需要 刷新位置,"浮动到顶部"
  this.clearAndInsertToHead(node);
  return node.value;
};

/** 
 * @param {number} key 
 * @param {number} value
 * @return {void}
 */
LRUCache.prototype.put = function(key, value) {
  // 首先确定一下,是否存在该值
  if (this.hashTable[key]) {
    // 存在的话,更新值。直接替换。 并且 刷新位置,"浮动到顶部"
    this.hashTable[key].value = value
    this.clearAndInsertToHead(this.hashTable[key]);
  } else {
    // 不存在的话,首先判断一下容量
    let newNode = new ListNode(key, value); // 新的node
    if (this.count === this.capacity) {
      // 关键!!! 容量不够的时候 删除最久没使用过的数据(其实就是把链表尾的数据删除)
      this.removeLRUItem();
    }
    this.hashTable[key] = newNode; // 把整个node存一下
    this.insertToHead(newNode);
    this.count++; //缓存数目 +1
  }
};

LRUCache.prototype.clearAndInsertToHead = function(node) {
  // 插入之前,先删除一下
  this.removeFromList(node);
  // 删除了之后,插入到头
  this.insertToHead(node);
};

LRUCache.prototype.removeFromList = function(node) {
  const preNode = node.prev; // 前节点
  const nextNode = node.next; // 后节点
  preNode.next = nextNode; // 前节点的next指向后节点
  nextNode.prev = preNode;
}

LRUCache.prototype.insertToHead = function(node) {
  node.prev = this.listHead; // 该节点的前指针为 虚拟头指针
  node.next = this.listHead.next; // 该节点的下一个指针为 原虚拟头指针的下一个指针
  this.listHead.next.prev = node; // 改变原头指针的钱指针为 新插入的node
  this.listHead.next = node; // 改变头指针的后指针为 新插入的node
} 

LRUCache.prototype.removeLRUItem = function(node) {
  let tailNode = this.popTail() // 将它从尾部删除
  delete this.hashTable[tailNode.key]; // 哈希表中也将它删除
  this.count--;
}

LRUCache.prototype.popTail = function() {
  const tailNode = this.listTail.prev; // 删除链表尾节点
  this.removeFromList(tailNode); // 删除真实尾结点
  return tailNode; // 返回被删除的节点
}

解题思路: 利用双向链表。解出这道题的关键是,需要维护一个双向链表。当数据被读取的时候,就需要将数据移动到顶部。当数据写入的时候两种情况: 1. 之前就存在的:更新数据,刷新位置。 2. 之前不存在的:有位置就直接写入,没有位置,就删掉最久没有使用的条目(链表尾),再写入。

148. 排序链表

分类:链表 | 双指针 | 分治 | 排序 | 归并排序

原文链接

给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表 。

// 归并 + 快慢指针
function sortList(head) {
  if (head === null || head.next === null) {
    // 归并排序:这里返回的 head 是有序的(因为是单个节点)
    return head;
  }
  // 通过快慢指针的方法将链表分成 [前半截,后半截]
  let fast = head.next; // !!! 这个地方很难想到! 快指针要先走一步(因为当链表中有两个数的时候,会一直在死循环)
  let slow = head;
  while(fast !== null && fast.next !== null) {
    fast = fast.next.next;
    slow = slow.next;
  }
  let firstHalf = head;
  let secondHalf = slow.next;
  slow.next = null;
  return merge(sortList(firstHalf), sortList(secondHalf));
}

// 合并两个有序列表
function merge(l1, l2) {
  console.log('l1, l2', l1, l2);
  const newList = new ListNode();
  let temp = newList;
  // 当l1、l2链表都还有下一个节点的时候,需要进行对比
  while(l1 && l2) {
    if (l1.val > l2.val) {
      temp.next = l2;
      l2 = l2.next;
    } else {
      temp.next = l1;
      l1 = l1.next;
    }
    temp = temp.next;
  }
  temp.next = l1 === null ? l2 : l1;
  return newList.next;
}

解题思路:归并 + 快慢指针:对链表进行排序:最适合链表的排序算法是归并排序(方法论来了)

归并排序的思想是:先递归的分解数组,再合并数组。链表中也是一样的:

  1. 先对链表进行递归分解,直到head.next = null || head === null。这个时候的子链表可以看做是有序的了
  2. 链表的拆分可以借助「快慢指针」,将链表拆成两份,直到不能分解为止
  3. 分解完成后,就做合并的操作,还可以借用21.合并两个有序链表来实现。

152. 乘积最大子数组

分类:数组 | 动态规划

原文链接

给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。 测试用例的答案是一个 32-位 整数。 子数组 是数组的连续子序列。

/**
 * @param {number[]} nums
 * @return {number}
 */
var maxProduct = function(nums) {
  // 思路: 由于数组中可能有负数,所以在遍历的过程中需要记录一下最大和最小的数
  let max = nums[0];
  let min = nums[0];
  let res = nums[0];
  for (let i = 1; i < nums.length; i++) {
    let tmp = min;
    min = Math.min(nums[i], Math.min(min * nums[i], max * nums[i])); // 取最小 (每次都把最小值记录一下,因为很有可能下一次就遇到负数, 负负得正了)
    max = Math.max(nums[i], Math.max(max * nums[i], tmp * nums[i])); // 取最大 (有可能负负得正) (这里只是记录一下阶段的最大值)
    res = Math.max(max, res);
  }
  return res;
};

解题思路:遍历这个数组,考虑到有可能出现负负得正的情况。所以遇到负数的时候不可以直接舍去,还是得先保留记录一下。另外,当遇到负数的时候,需要从下一个正数开始重置(在 max = Math.max(nums[i], Math.max(max * nums[i], tmp * nums[i])); 这段逻辑里,可以体会一下)。

155. 最小栈

分类:栈 | 设计

原文链接

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

MinStack() 初始化堆栈对象。
void push(int val) 将元素val推入堆栈。
void pop() 删除堆栈顶部的元素。
int top() 获取堆栈顶部的元素。
int getMin() 获取堆栈中的最小元素。
var MinStack = function() {
  this.stack = []
  this.min_stack = [Infinity]
};

MinStack.prototype.push = function(val) {
  this.stack.push(val)
  this.min_stack.push(Math.min(val, this.min_stack[this.min_stack.length - 1])) // 每次都记录一下当前的最小值
};

MinStack.prototype.pop = function() {
  this.stack.pop()
  this.min_stack.pop() // 当前的最小值同时出栈,相当于回退到上一个状态(最小值也可以取上一个状态)
};

MinStack.prototype.top = function() {
  return this.stack[this.stack.length - 1]
};

MinStack.prototype.getMin = function() {
  return this.min_stack[this.min_stack.length - 1]
};

解题思路:手动实现一个栈。重点思路:维护一个数组来存储每个状态小的最小值,当出栈的时候,同时将min_stack出栈(相当于回到上一个状态)。

160. 相交链表

分类:哈希表 | 链表 | 双指针

原文链接

给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null 。

var getIntersectionNode = function(headA, headB) {
  if (!headA || !headB) {
    return null;
  }
  // 用hash存
  const hashMap = new Map();
  while(headA) {
    hashMap.set(headA, '');
    headA = headA.next;
  }

  while(headB) {
    if (hashMap.has(headB)) {
      return headB;
    }
    headB = headB.next;
  }
  return null;
};

解题思路一:hash 表。先遍历一遍链表A,用hash表把每个节点都记录下来(注意要存节点引用而不是节点值)。然后再遍历节点B,找到在hash表中出现过的节点即为两个链表的交点。

// 双指针法
var getIntersectionNode = function (headA, headB) {
  if (!headA || !headB) {
    return null;
  }
  let pA = headA;
  let pB = headB;
  while(pA !== pB && pA && pB) {
    if (pA === pB) {
      return pA;
    }
    pA = pA.next ? pA.next : headB;
    pB = pB.next ? pB.next : headA;
  }
  return null;
}

解题思路二:巧用双指针。使用两个指针pA、pB分别去遍历headA、headB。当headA遍历完了之后,遍历headB。headB遍历完了遍历headA。当headA和headB相等的时候就是相交的点。

证明:假设headA到相交点的距离为a。headB到相交点的距离为b。剩余长度为c。那么当第二次走到相交点的时候,pA走过的路程为 a + c + b。pB走过的路程为b + c + a。

198. 打家劫舍

分类:数组 | 动态规划

原文链接

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function (nums) {
  let dp = new Array(nums).fill(false);
  dp[0] = nums[0];
  dp[1] = Math.max(nums[0], nums[1]);
  for (let i = 2; i < nums.length; i++) {
    // 假设,当前的值和前前家的加起来大于偷上一家的最大值。
    if (nums[i] + dp[i - 2] > dp[i - 1]) {
      dp[i] = dp[i - 2] + nums[i]
    } else {
      dp[i] = dp[i - 1]
    }
  }
  return dp[nums.length - 1];
};

解题思路:动态规划。思路:

由于不可以在相邻的房屋传入,所以在当前位置n房屋可盗窃的最大值,要么就是n - 1房屋的可盗窃最大值,要么就是n - 2房屋可盗窃的最大值加上当前房屋的可盗窃值。

**具体思路:**动态规划三部曲

  1. 定义dp[i]:到当前位置房屋可盗窃的最大值
  2. 状态转移方程: dp[i] = Math.max(dp[i] + dp[i - 2], dp[i - 1])
  3. 初始化 Base Case:dp[0] = nums[0]dp[1] = Math.max(nums[0], nums[1])

200. 岛屿数量

分类:深度优先搜索 | 广度优先搜索 | 并查集 | 数组 | 矩阵

原文链接

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。 岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。 此外,你可以假设该网格的四条边均被水包围。

/**
 * @param {character[][]} grid
 * @return {number}
 */
var numIslands = (grid) => {
  let count = 0;
  const row = grid.length; // 行
  const col = grid[0].length; // 列
  // 对当前块做沉岛操作,不仅如此,对其上下左右依次做沉岛操作
  const turnToZero = (i, j) => {
    // 停止递归的条件:下标越界、当前为0
    if (
      i < 0 || i >= row || 
      j < 0 || j >= col ||
      grid[i][j] === '0'
    ) {
      return
    } else {
      // 否则,递归上下左右。并对当前做沉岛操作
      grid[i][j] = '0';
      turnToZero(i + 1, j);
      turnToZero(i - 1, j);
      turnToZero(i, j + 1);
      turnToZero(i, j - 1);
    }
  }
  for (let i = 0; i < row; i++) {
    for (let j = 0; j < col; j++) {
      if (grid[i][j] === '1') {
        count += 1;
        turnToZero(i, j)
      }
    }
  }
  return count;
}

解题思路:深度优先:对为"1"的块做”沉岛“操作。

  1. 首先找到1即遇到岛屿,计数 count + 1
  2. 将遍历过的岛屿标识为0(沉岛),否则下次遍历到会做重复计算(试想一下上下上下来回跳,肯定死循环)。
  3. 以当前的岛屿为入口,进行DFS,遇到以下几种情况结束递归
    • 行下标越界
    • 列下标越界
    • 遇到0的时候
  4. 返回count

206. 反转链表

分类:递归 | 链表

原文链接

/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var reverseList = function(head) {
  let prev = null;  // 可以理解为 prev 指针是current的前一个,刚刚开始为null
  let current = head;
  while(current) {
    // 用于临时存储的 head 后继指针
    const currentNext = current.next;

    // 翻转
    current.next = prev;

    // prev 和 current 指针都向前走一步
    prev = current;
    current = currentNext;
  }
  // 最终 prev 指向了原链表的表尾,也就是翻转后链表的表头
  return prev;
}

解题思路: 迭代。解题思路:

  • 反转链表需要涉及到3个指针,[当前指针的前指针,当前指针,当前指针的后指针]。
  • 由于做翻转操作,所以链表一定会断开,所以需要一个全局变量来记录一下已经反转好了的链表的表尾
  • 实际上,当前指针的前指针就是已经反转好了的链表的表尾,所以这一个变量有两层含义。
  • 还需要一枚current指针来遍历当前的链表。

具体做法

  1. current从链表的表头开始
  2. 先把 current.next保存一下,不然反转一下,「当前指针的后指针」就拿不到了
  3. 反转比较简单,直接改变current.next就行
  4. prev, current 都在原来的位置上后移一位
  5. 最终 prev 指向了原链表的表尾,也就是翻转后链表的表头。最后返回 pre 指针就好。

207. 课程表

分类:深度优先搜索 | 广度优先搜索 | 图 | 拓扑排序

原文链接

你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。 在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则 必须 先学习课程 bi 。

例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1 。

请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。

var canFinish = (numCourses, prerequisites) => {
  const inDegree = new Array(numCourses).fill(0); // 求课的初始入度值 (也就是需要提前完成几门前置课程)
  const map = {}; // 邻接表 (也就是修完当前这门课程,有哪些课程的前置课程能 - 1)
  for (let i = 0; i < prerequisites.length; i++) {
    inDegree[prerequisites[i][0]]++;
    if (map[prerequisites[i][1]]) {
      // 当前课已经存在于邻接表
      map[prerequisites[i][1]].push(prerequisites[i][0]); // 添加依赖它的后续课
    } else {
      // 当前课不存在于邻接表
      map[prerequisites[i][1]] = [prerequisites[i][0]];
    }
  }
  // inDegree: [0, 0, 0, 2, 2, 2]
  // map: {
  //   0: [3],
  //   1: [3, 4],
  //   2: [4],
  //   3: [5],
  //   4: [5],
  // };
  const queue = [];
  for (let i = 0; i < inDegree.length; i++) {
    // 所有入度为0的课入列 (不需要休前置课的先学了)
    if (inDegree[i] == 0) queue.push(i);
  }
  // 那么 queue 就是先被选择的那几门课
  let count = 0;
  while (queue.length) {
    const selected = queue.shift(); // 当前选的课,出列
    count++; // 选课数+1
    const toEnQueue = map[selected]; // 获取依赖这门课作为前置课程的课: [3]
    if (toEnQueue && toEnQueue.length) {
      // 确实有后续课
      for (let i = 0; i < toEnQueue.length; i++) {
        inDegree[toEnQueue[i]]--; // 依赖它的后续课的入度-1
        if (inDegree[toEnQueue[i]] == 0) {
          // 如果因此减为0,入列 queue
          queue.push(toEnQueue[i]);
        }
      }
    }
  }
  return count == numCourses; // 选了的课等于总课数,true,否则false
};
//  0    
//                3

//  1                        5
 
//                 4
//  2

解题思路:拓扑图

  • 一共有 n 门课要上,编号为 0 ~ n-1。
  • 先决条件[1, 0],意思是必须先上课 0,才能上课 1。
  • 给你 n 、和一个先决条件表,请你判断能否完成所有课程。
  • 示例:n = 6,先决条件表:[[3, 0], [3, 1], [4, 1], [4, 2], [5, 3], [5, 4]],可以用有向图来展示这种依赖关系(代码中备注所示)

具体做法

  1. 首先用一个inDegree来表示每门课的前置课程数
  2. 用一个map来记录修完当前这门课程,有哪些课程的前置课程能 - 1
  3. 用queue从inDegree中筛选出前置课程数为0的课程
  4. 不断将queue弹栈,表示将前置课数为0的课程上掉。此时count++
  5. 同时,从map中找出所有修完这门课,前置课程数能-1的课程,将其的前置课程数 - 1。
  6. 返回 count === numCourses

208. 实现 Trie (前缀树)

分类:设计 | 字典树 | 哈希表 | 字符串

原文链接

Trie(发音类似 "try")或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补完和拼写检查。 请你实现 Trie 类:

Trie() 初始化前缀树对象。 void insert(String word) 向前缀树中插入字符串 word 。 boolean search(String word) 如果字符串 word 在前缀树中,返回 true(即,在检索之前已经插入);否则,返回 false 。 boolean startsWith(String prefix) 如果之前已经插入的字符串 word 的前缀之一为 prefix ,返回 true ;否则,返回 false 。

const TreeNode = function (val, childList) {
  return {
    val,
    childList,
    isEnd: false,
  };
};

var Trie = function () {
  // 初始化函数
  this.rootNode = new TreeNode("", []);
};

/**
 * @param {string} word
 * @return {void}
 */
Trie.prototype.insert = function (word) {
  const findInsertParentNode = (node, wordStartIndex) => {
    if (node.childList.length === 0) {
      return {
        startNode: node,
        wordStartIndex, // 从 wordStartIndex 开始就没有重合的了
      };
    }
    for (let i = 0; i < node.childList.length; i++) {
      const index = node.childList.findIndex(
        (item) => item.val === word[wordStartIndex]
      );
      // 2. 确定递归结束的条件(当前层找不到和word[index]重合的字符)
      if (index === -1) {
        // 把上一层return 回去
        return {
          startNode: node,
          wordStartIndex, // 从 wordStartIndex 开始就没有重合的了
        };
      }
      // 3. 确定每次递归需要处理的问题: 对匹配上的继续递归
      return findInsertParentNode(node.childList[index], wordStartIndex + 1);
    }
  };
  // 1. 参数一: 表示插入函数的第一个字符 参数二:表示当前查找的层级(从根节点开始找)
  // 没有找到的话就直接在根节点开始插入
  const { startNode, wordStartIndex } = findInsertParentNode(this.rootNode, 0);

  // 从定位到的地方开始插入
  const insertAsTree = (startNode, wordStartIndex) => {
    // 假设在 apple 插入后 插入 app 的情况
    // if (wordStartIndex === word.length) {
    //   startNode.isEnd = true; // 用来判断 app 和 apple。在search的时候是否能完全匹配
    // } 
    // 递归插入,直到word被遍历完
    while (wordStartIndex < word.length) {
      startNode.childList.push(TreeNode(word[wordStartIndex], []));
      wordStartIndex += 1;
      // startNode 改变为刚插入的那个节点
      startNode = startNode.childList[startNode.childList.length - 1];
      if (wordStartIndex === word.length) {
        startNode.isEnd = true;
      }
    }
    startNode.isEnd = true;
  };
  insertAsTree(startNode, wordStartIndex);
};

/**
 * @param {string} word
 * @param {string} mode
 * @return {void}
 */
Trie.prototype.searchStr = function (word, mode) {
  // 这里就比较简单了,就是一个递归查找
  const rootNode = this.rootNode;
  const dfs = (node, depth) => {
    const index = node.childList.findIndex((item) => item.val === word[depth]);
    // 当某一层匹配不上时
    if (index === -1) {
      return false;
    }
    // 当最后一层都能匹配上时
    if (index !== -1 && depth === word.length - 1) {
      // 这里还要增加判断一下是否是完全匹配
      if (mode === "startsWith") {
        return true;
      } else if (mode === "search") {
        return node.childList[index].isEnd;
      }
    }
    return dfs(node.childList[index], depth + 1);
  };
  return dfs(rootNode, 0);
};

/**
 * @param {string} word
 * @return {boolean}
 */
Trie.prototype.search = function (word) {
  return this.searchStr(word, "search");
};

/**
 * @param {string} prefix
 * @return {boolean}
 */
Trie.prototype.startsWith = function (prefix) {
  return this.searchStr(prefix, "startsWith");
};

/**
 * Your Trie object will be instantiated and called as such:
 * var obj = new Trie()
 * obj.insert(word)
 * var param_2 = obj.search(word)
 * var param_3 = obj.startsWith(prefix)
 */

var obj = new Trie();
obj.insert("apple");
obj.insert("app");
console.log('obj.rootNode', obj.rootNode);
console.log("obj.search(app)", obj.startsWith("ap"));
// var param2 = obj.search(word);
// var param3 = obj.startsWith(prefix);

解题思路: 用树来存储

  1. 首先确定用树来存储数据结构apple和appld就能公用之前的路径。
  2. 每次插入前需要先找到公共部分,再把剩余部分插入
  3. 查找分为两种模式:完全匹配和前缀匹配
  4. 基于3所以在插入的时候,就需要记录一下当前是否有插入过完整的数据(isEnd)

215. 数组中的第K个最大元素

分类:数组 | 分治 | 快速选择 | 排序 | 堆(优先队列)

原文链接

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。 请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。 你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
 var findKthLargest = function(nums, k) {
  // 从大到小排序后,取第 k - 1 个
  return (nums.sort((a, b) => b - a))[k - 1]
};

解题思路:排序从大到小排序后,取第 k - 1 个。

**Tips:**topK的问题可以使用堆排序来做,但是JS并不提供这种数据结构,得自己写一个堆

238. 除自身以外数组的乘积

分类:数组 | 前缀和

原文链接

给你一个整数数组 nums,返回 数组 answer ,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积 。 题目数据 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。 请不要使用除法,且在 O(n) 时间复杂度内完成此题。

var productExceptSelf = function (nums) {
  // answer[i] = 左边的数之积 ∗ 右边的数之积
  // 我们用一个数组来存储 左边的数之积 - productLeft[i]
  // 我们用另一个数组来存储 右边的数之积 - productRight[i]
  // answer[i] = productLeft[i] * productRight[i]
  const productLeft = new Array(nums.length);
  const productRight = new Array(nums.length);
  const answer = new Array(nums.length);
  productLeft[0] = 1;
  productLeft[1] = nums[0];
  productRight[nums.length - 1] = 1;
  productRight[nums.length - 2] = nums[nums.length - 1];
  for (let i = 2; i < nums.length; i++) {
    productLeft[i] = nums[i - 1] * productLeft[i - 1];
  }
  for (let i = nums.length - 3; i >= 0; i--) {
    productRight[i] = nums[i + 1] * productRight[i + 1];
  }
  console.log('productLeft', productLeft);
  console.log('productRight', productRight);
  for (let i = 0; i < nums.length; i++) {
    answer[i] = productLeft[i] * productRight[i];
  }
  console.log('answer', answer);
  return answer;
};

解题思路:左边的数之积 ∗ 右边的数之积

  1. 我们用一个数组来存储 左边的数之积 -- productLeft[i]
  2. 我们用另一个数组来存储 右边的数之积 -- productRight[i]
  3. answer[i] = productLeft[i] * productRight[i]

221. 最大正方形

分类:数组 | 动态规划 | 矩阵

原文链接

在一个由 '0' 和 '1' 组成的二维矩阵内,找到只包含 '1' 的最大正方形,并返回其面积。

 * @param {character[][]} matrix
 * @return {number}
 */
var maximalSquare = function (matrix) {
  const row = matrix.length;
  const column = matrix[0].length;
  const dp = new Array(row).fill(false).map(() => new Array(column).fill(false));
  let maxSideLength = 0;
  // 初始化Base
  for (let i = 0; i < row; i++) {
    dp[i][0] = matrix[i][0];
    maxSideLength = Math.max(dp[i][0], maxSideLength); // 考虑到 [["0","1"],["1","0"]] 这个case
  }
  for (let j = 0; j < column; j++) {
    dp[0][j] = matrix[0][j];
    maxSideLength = Math.max(dp[0][j], maxSideLength); // 考虑到 [["0","1"],["1","0"]] 这个case
  }

  for (let i = 1; i < row; i++) {
    for (let j = 1; j < column; j++) {
      // 状态转移方程
      if (matrix[i][j] > 0) {
        dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1;
      } else {
        dp[i][j] = 0;
      }
      maxSideLength = Math.max(dp[i][j], maxSideLength);
    }
  }
  return maxSideLength * maxSideLength;
};

解题思路:动态规划:明确思路:

  • 找出最大正方形等价于找出最大边长
  • 如果是0的话则不是我们需要的、1则能形成边长为1的正方形
  • dp[i][j]: 以(i,j)为右下顶点所能形成的最大正方形的边长(这里明确为右下顶点很关键,这样就能保证我们的动态规划的单向性,不会重复计算)
  • dp[i][j]为右下顶点形成的最大正方形,取决于它左边、上方、左上方的项所能形成的最小正方形 + 1

具体做法

  1. 定义dp[i][j]:以(i,j)为右下顶点所能形成的最大正方形的边长
  2. 状态转移方程:matrix[i][j] > 0 ? dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1 : dp[i][j] = 0
  3. 初始化Base Case:dp[*][0] = matrix[*][0]dp[0][*] = matrix[0][*]

226. 翻转二叉树

分类:树 | 深度优先搜索 | 广度优先搜索 | 二叉树

原文链接

给你一棵二叉树的根节点 root ,翻转这棵二叉树,并返回其根节点。

/**
 * @param {TreeNode} root
 * @return {TreeNode}
 */
 var invertTree = function (root) {
  if (!root){
    return null
  }
  let newRoot = new TreeNode();
  // 递归三部曲: 1.确定递归函数参数和返回值
  const traverse = (node, newNode) => {
    // 递归三部曲: 3.结束条件
    if (!node) {
      return;
    }
    newNode.val = node.val;
	// 重点!! 提前初始化一下
    if (node.right) {
      newNode.left = new TreeNode();
    }
    // 重点!! 提前初始化一下
    if (node.left) {
      newNode.right = new TreeNode();
    }
    traverse(node.right, newNode.left);
    traverse(node.left, newNode.right);
    // 递归三部曲: 2.确定每一步要处理的问题
  }
  traverse(root, newRoot);
  return newRoot;
};

解题思路:递归遍历。翻转的时候,相当于遍历原二叉树的左节点的时候,要往新二叉树的右节点插。这里要特别注意的是,要提前初始化一下数据结构,才能在新二叉树上直接赋值。

234. 回文链表

分类:栈 | 递归 | 链表 | 双指针

原文链接

给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false 。

var isPalindrome = function (head) {
  const stack = [];
  let temp = head;
  // 先用栈收集了一下顺序,然后利用栈先入后出的特性。
  while (temp) {
    stack.push(temp);
    temp = temp.next;
  }
  // 将原链表和栈进行比对。
  while (head) {
    if (head.val !== stack.pop().val) {
      return false;
    }
    head = head.next;
  }
  return true;
};

解题思路:利用栈。因为栈是后入先出的,那么就利用栈的特性,先收集一下节点的顺序,然后逆序弹出。和原链表进行对比。

var isPalindrome = function (head) {
  // 这句可有可无,毕竟题目中说了链表长度至少为1
  if (!head) return true;
  let slow = head,
    fast = head.next;
  while (fast && fast.next) {
    slow = slow.next;
    fast = fast.next.next;
  }
  let back = reverseList(slow.next);
  while (back) {
    if (head.val !== back.val) {
      return false;
    }
    head = head.next;
    back = back.next;
  }
  return true;
};

var reverseList = function(head) {
  let pre = null;
  while(head) {
    // 用于临时存储的 head 后继指针
    const temp = head.next;
    head.next = pre;
    pre  = head;
    head = temp;
  }
  return pre;
}

解题思路二:双指针

  1. 因为我们要判断回文字符串,其实就是判断后半段的字符串翻转后是否和前半段相同。

  2. 所以问题就变成了如何对半截断链表。我们可以利用快慢指针。快指针一次性走两步,慢指针一次性走一步。

  3. 快指针走到底后,只需要翻转慢指针到链表尾的后半截链表,用反转后的链表和原链表进行比对即可。

分类:

原文链接

1

解题思路:

236. 二叉树的最近公共祖先

分类:树 | 深度优先搜索 | 二叉树

原文链接

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。 百度百科中最近公共祖先的定义为:“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。

var lowestCommonAncestor = function (root, p, q) {
  let ans;
  const dfs = (node, p, q) => {
    if (node === null) return null;
    const lson = dfs(node.left, p, q); // 该节点的左节点是否包含 p || q
    const rson = dfs(node.right, p, q); // 该节点的右节点是否包含 p || q
    // 1. 如果p,q分部在 node 两侧,那么 node 就是LCA
    // 2. 还有一种情况,node就是p/q,且剩下的q/p是node的子节点,那么node就是LCA
    if (
      (lson && rson) ||
      ((node.val === p.val || node.val === q.val) && (lson || rson))
    ) {
      ans = node;
    }
    return node.val === q.val || node.val === p.val || lson || rson 
  };
  dfs(root, p, q);
  return ans;
};

解题思路:递归:首先我们明确一下,node为p、q最近公共祖先的两种可能性:

  • p、q分布在node两侧;
  • node为p || q,另外一个q || p在node的子节点中;

递归寻找每个节点:p || q是否是它的子节点(是他本身也可以)。如果遇到(lson && rson) || ((node.val === p.val || node.val === q.val) && (lson || rson)),记录一下该节点。

240. 搜索二维矩阵 II

分类:数组 | 二分查找 | 分治 | 矩阵

原文链接

编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性:

每行的元素从左到右升序排列。 每列的元素从上到下升序排列。

var searchMatrix = function (matrix, target) {
  const row = matrix.length;
  const column = matrix[0].length;
  if (matrix[0][0] === target) {
    return true;
  }
  let i = 0;
  let j = column - 1;
  while (i <= row - 1 && j >= 0) {
    if (matrix[i][j] === target) {
      return true;
    }
    target > matrix[i][j] ? (i += 1) : (j -= 1);
  }
  return false;
};

解题思路:从右上角开始遍历:从右上角开始遍历,那么这个矩形就变成向左单调递减,向下单调递增。当前位置和target进行对比,如果小了就向下走,如果大了就向左走

279. 完全平方数

分类:广度优先搜索 | 数学 | 动态规划

原文链接

给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。 完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

/**
 * @param {number} n
 * @return {number}
 */
var numSquares = function(n) {
  // 定义dp[i]:和为 i 的完全平方数的最小数量
  const dp = new Array(n + 1).fill(false);
  // 初始化 Base Case:
  dp[0] = 0;
  dp[1] = 1;
  // 计算dp[i]
  for (let i = 2; i < n + 1; i++) {
    dp[i] = i; // 默认设一个最大值为i(比如4 = 1 + 1 + 1 + 1 就是最坏的情况)
    // 枚举 [1, √i]的所有情况
    for (let j = 1; i - j * j >= 0; j++) {
      dp[i] = Math.min(dp[i - j * j] + 1, dp[i]);
    }
  }
  console.log(dp);
  return dp[n];
};

解题思路:动态规划。思路:

  • 举个例子,要算和为 17 的完全平方数的最少数量。那么其中的完全平方数 sqrNum 应该在 1 <= sqrNum <= √17
  • 所以我们可以枚举这些情况。
    1. 假设最后一个完全平方数为1,那么和为 17 的完全平方数的最少数量 = 和为 16 (17 - 1 * 1) 的完全平方数的最少数量 + 1
    2. 假设最后一个完全平方数为2,那么和为 17 的完全平方数的最少数量 = 和为 13(17 - 2 * 2)的完全平方数的最少数量 + 1
    3. ...... 一直可以枚举到 4
  • 那么如何算和为16、13的完全平方数的最小数量呢?和上述过程一样,这符合了动态规划的思路,可以用动态规划三部曲来解题。

具体做法

  1. 定义dp[i]:和为 i 的完全平方数的最小数量
  2. 状态转移方程: dp[i] = Math.min(dp[i - 1 * 1] + 1, dp[i - 2 * 2] + 1, ...) ,注意dp的下标要大于零。
  3. 初始化Base Case:dp[0] = 0dp[1] = 1

283. 移动零

分类:数组 | 双指针

原文链接

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。 请注意 ,必须在不复制数组的情况下原地对数组进行操作。

/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var moveZeroes = function (nums) {
  let current = 0; // current指针来遍历数组
  let partition = 0; // partition指针用来分区,保证该指针之前的所有数都为非0的数
  while (current < nums.length) {
    // 如果遍历到非零数,交换`current`和`partition`的数,并且`partition++`、同时` current++`。
    if (nums[current] !== 0) {
      [nums[current], nums[partition]] = [nums[partition], nums[current]];
      partition++;
      current++;
    } else {
      // 遍历到数字零,仅`current++`(这个地方的零会有两种结果:1. 之后会被非0的数覆盖  2. 在这个位置保持不动,但是位置肯定也是正确的)。
      current++;
    }
  }
};

解题思路:双指针 + 分区

  1. 定义两枚指针:current指针来遍历数组、 partition指针用来分区,保证该指针之前的所有数都为非0的数。

  2. 如果遍历到非零数,交换currentpartition的数,并且partition++、同时 current++

  3. 遍历到数字零,仅current++(这个地方的零会有两种结果:1. 之后会被非0的数覆盖 2. 在这个位置保持不动,但是位置肯定也是正确的)。

**Tips:**模式识别:原地基本上意味着需要用交换来实现。如果是操作数组,为了不频繁移动数组,大概率使用双指针。

287. 寻找重复数

分类:位运算 | 数组 | 双指针 | 二分查找

原文链接

给定一个包含 n + 1 个整数的数组 nums ,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数。 假设 nums 只有 一个重复的整数 ,返回 这个重复的数 。 你设计的解决方案必须 不修改 数组 nums 且只用常量级 O(1) 的额外空间。

/*
 * @param {number[]} nums
 * @return {number}
 */
var findDuplicate = function (nums) {
  // 首次的想法,遍历的时候,用hashMap。但是好像不满足 O(1)的空间要求
  // 模式识别: 出现次数: hash表
  const hashMap = {}
  for (let i = 0; i < nums.length; i++) {
    if (hashMap[nums[i]]) {
      return nums[i]
    } else {
      hashMap[nums[i]] = true;
    }
  }
};

解题思路一:Hash表,出现次数: 使用Hash表来存储,但是好像不满足O(1)的空间要求

var findDuplicate = function(nums) {
  var fast = 0;
  let slow = 0;
  while(true) {
    slow = nums[slow]; // 1. 慢指针走一步
    fast = nums[nums[fast]]; // 1. 快指针走两步
    // 2. 快慢指针首次相遇  
    if (slow === fast) {
      fast = 0; // 3. 让快指针回到起点 
      if (nums[slow] === nums[fast]) { // 5. 如果再次相遇,就肯定是在入口处?!!(这个怎么理解????)
        return slow
      }
      slow = nums[slow]; // 4. 两个指针每次都进一步 (因为)
      fast = nums[fast];
    }
  }
}

解题思路二:快慢指针:题目说数组必存在重复数,所以 nums 数组肯定可以抽象为有环链表。

首先,如果有环的话,那么快慢指针一定会在环内相遇。

  1. 相遇时,慢指针走的距离:D+S1D+S1

  2. 假设相遇时快指针已经绕环 n 次,它走的距离:D+n(S1+S2)+S1D+n(S1+S2)+S1

  3. 因为快指针的速度是 2 倍,所以相同时间走的距离也是 2 倍:

    D+n(S1+S2)+S1 = 2(D+S1)D+n(S1+S2)+S1=2(D+S1)

    即 (n-1)S1+ nS2=D(n−1)S1+nS2=D

  4. 我们不关心绕了几次环,取 n = 1 这种特定情况,消掉 S1: D=S2

所以此时,让快指针回到原点,当下次慢指针和快指针相遇的时候,就是在环的入口处。

参考链接:leetcode.cn/problems/li…

300. 最长递增子序列

分类:数组 | 二分查找 | 动态规划

原文链接

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。 子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。 示例 1: 输入:nums = [10,9,2,5,3,7,101,18] 输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function(nums) {
  // 含第 i 个元素的最长上升子序列的长度。
  // 在这里顺便做了一个 初始化 Base Case
  const dp = new Array(nums.length).fill(1);
  let res = dp[0];
  for (let i = 1; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      // dp[i] 有个默认的初始化值 为1
      if (nums[i] > nums[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1)        
      } else {
        dp[i] = Math.max(dp[i], dp[j]);
      }
    }
    res = Math.max(res, dp[i]);
  }
  return res;
}

解题思路:动态规划,思路:

  • 使用数组dp来保存到当前下标时的递增子序列长度
  • 求解dp[i]时,向前遍历比i小的元素j,如果当前值nums[i] > nums[j]dp[i] = dp[j] + 1,每遍历到一个dp[j]时,都更新一下dp[i],保证dp[i]是最大值。

具体做法

  1. 定义dp[i]:含第 i 个元素的最长上升子序列的长度。
  2. 状态转移方程:nums[i] > nums[j] ? dp[i] = dp[j] + 1 : dp[i] = dp[j],其中,0 <= j < i。
  3. 初始化 Base Case:dp[0] = 1

322. 零钱兑换

分类:广度优先搜索 | 数组 | 动态规划

原文链接

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。 计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。 你可以认为每种硬币的数量是无限的。

/**
 * @param {number[]} coins
 * @param {number} amount
 * @return {number}
 */
var coinChange = function (coins, amount) {
  if (amount === 0) {
    return 0;
  }
  // 初始化Base Case 1
  const dp = new Array(amount + 1).fill(-1);
  // 初始化Base Case 2
  for (coin of coins) {
    dp[coin] = 1;
  }
  for (i = 1; i < amount + 1; i++) {
    for (coin of coins) {
      // 考虑下标越界的情况 && 上一步的硬币数是可以组成的
      if (i - coin >= 0 && dp[i - coin] > 0) {
        // 先确保 dp[i] 能被组成不为 -1。 再考虑取较小值
        if (dp[i] > 0) {
          dp[i] = Math.min(dp[i], dp[i - coin] + 1)
        } else {
          dp[i] = dp[i - coin] + 1;
        }
      }
    }
  }
  console.log(dp);
  return dp[amount]
};

解题思路:动态规划:思路:

  • 假设给出[1, 2, 5]面额的硬币数,目标是120,需要多少个硬币?
  • 求总金额 120 有几种方法?三种。
    1. 拿一枚面值为 1 的硬币 + 总金额为 119 的最少的硬币个数
    2. 拿一枚面值为 2 的硬币 + 总金额为 118 的最少的硬币个数
    3. 拿一枚面值为 5 的硬币 + 总金额为 115 的最少硬币个数
  • 那么面值为 119、118、115的最少硬币数是多少呢,那么就需要知道 119 - 1、 119 - 2、 119 -5 的最少硬币数

具体做法

  1. 定义dp[i]:面额为 i 的总金额,最少需要多少枚硬币
  2. 状态转移方程: dp[i] = Math.min(dp[i - coins[0]] + 1, d[i - coins[1]] + 1, ...)(注意!这里的dp[i]为 -1的时候,直接等于dp[i - coin] + 1,先确保 dp[i] 能被组成不为 -1。 再考虑取较小值)
  3. 初始化Base Case:dp[*] = -1dp[coin[0]] = 1、dp[coin[1]] = 1、dp[coin[2]] = 1...

Tips: 本类题目可以使用「自顶向下」思想来考虑这个题目,然后用「自底向上」的方法来解题。

647. 回文子串

分类:字符串 | 动态规划

原文链接

给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。 回文字符串 是正着读和倒过来读一样的字符串。 子字符串 是字符串中的由连续字符组成的一个序列。 具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。

/**
*
*/
var countSubstrings = (s) => {
    let count = 0;
    let len = s.length;
    const dp = new Array(len).fill(false).map(_ => new Array(len).fill(false));

    // 注意!!!这个遍历的顺序也很重要
    for (let j = 0; j < len; j++) {
      for (let i = 0; i <= j; i++) {
        console.log('i,j', i,j);
        // 分三种情况:长度为1、长度为2、长度大于2
        // 长度为1的情况下,必定相等
        if (i === j) {
          dp[i][j] = true;
          count++;

        } else if (j - i === 1 && s[i] === s[j]) {
          // 长度为2的情况下,判断是否相等
          dp[i][j] = true;
          count++;

        } else if (j - i > 1 && s[i] === s[j] && dp[i + 1][j - 1]) {
           // 长度 > 2的情况下,取决于dp[i + 1][j - 1] 并且 s[i] === s[j]同时满足
          dp[i][j] = true;
          count++;
        }
      }
    }

    return count;
}

解题思路:动态规划:分三种情况讨论

  1. 当i === j 的时候,无脑为true
  2. 当j - i === 1的时候,判断字符串是否相等
  3. 当j - i > 1的时候,取决于dp[i + 1][j - 1] 并且 s[i] === s[j]同时满足(相当于cabac,需要满足c === c,并且aba是回文字符串)

*这题还要特别注意推导base case:需要通过i,j的顺序来控制