【持续更新中】代码随想录 + labuladong 算法总结

1,123 阅读21分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

概述

本篇文章是我个人刷代码随想录的总结,其中有labuladong的题解的地方也会进行总结,全都是最简洁的关键点的总结,没有过多的解释,仅仅是为了能够一看到这些关键点就立刻能够想起思路

建议在看了两位大佬的原文章后再来看相应的总结会最为合适,再次声明,本篇文章不是教程,只是总结关键点!!!

代码随想录:programmercarl.com/ labuladong:labuladong.github.io/

十分感谢有这两位大佬发布了这么优质的算法系列文章!

注:文章中使用的编程语言为**TypeScript**


1. 数组

总结

1. 移除数组元素时的注意点

对于数组元素移动的时候,如果是用暴力法,应当注意让外层大循环的i的相对往前移动,从而抵消i++的影响

for (let j = i + 1; j < nums.length; j++) {
  nums[j - 1] = nums[j];
}
i--; // i 也要相对往前移动一位

2. 封装计数器Counter简化Map的使用

使用哈希表的时候经常会遇到要set一个keyvalue,当key不存在时希望set为1,存在的时候希望是在原来的value的基础上自增1,也就是类似一个计数器counter的操作

因此可以实现一个Map的子类Counter,它有count方法,能够给一个key计数 还有decrease方法,能够减少一个key的计数,只在key存在的时候才会减少

/**
 * @description 计数器 -- 使用 Map 实现
 */
class Counter<K = any> extends Map<K, number> {
  /**
   * @description 给 key 计数 key 存在时自增 1,不存在时则为 1
   * @param key key
   * @param step 步长 -- 默认为 1,即每次自增 1
   */
  count(key: K, step: number = 1) {
    if (this.has(key)) {
      this.set(key, this.get(key)! + step);
    } else {
      this.set(key, 1);
    }
  }

  /**
   * @description 减少 key 的计数,只会在 key 存在的时候减少
   * @param key key
   * @param step 步长 -- 默认为 1,即每次自减 1
   */
  decrease(key: K, step: number = 1) {
    if (this.has(key)) {
      this.set(key, this.get(key)! - step);
    }
  }
}

1.1 二分查找

条件:

  1. 有序
  2. 无重复元素(有重复时结果不唯一)

关键:

  1. mid的取值要用left + Math.floor((right - left) / 2)防止整数溢出
  2. 明确二分的区间
    1. 左闭右闭
      1. right = nums.length - 1
      2. left <= right
      3. right = mid - 1
    2. 左闭右开
      1. right = nums.length
      2. left < right
      3. right = mid
/**
 * @description 区间 -- []
 */
function search(nums: number[], target: number): number {
  let left = 0;
  let right = nums.length - 1;

  while (left <= right) {
    const mid = left + Math.floor((right - left) / 2);
    const num = nums[mid];

    if (target < num) {
      right = mid - 1;
    } else if (target > num) {
      left = mid + 1;
    } else {
      return mid;
    }
  }

  return -1;
}
/**
 * @description 区间 -- [)
 */
function search(nums: number[], target: number): number {
  let left = 0;
  let right = nums.length;

  while (left < right) {
    const mid = left + Math.floor((right - left) / 2);
    const num = nums[mid];

    if (target < num) {
      right = mid;
    } else if (target > num) {
      left = mid + 1;
    } else {
      return mid;
    }
  }

  return -1;
}

1.1.1 搜索插入位置

力扣第 35 题

image.png 这题是在二分搜索的基础上增加了一个条件,如果找不到元素就需要找出它应该放在数组中的哪个位置 只需要画图看一下最终跳出循环时整个数组中指针的指向关系就很容易看出答案**(使用左闭右闭的二分边界)** image.png 最终一定是rightleft相邻,且rightleft的左边

那么只需要判断一下target是比right指向的值大还是小,然后就知道该放到哪里了 return target < nums[right] ? right : right + 1

事实上,最终的target一定是在left位置处 image.png image.png 因此直接返回left即可

function searchInsert(nums: number[], target: number): number {
  let left = 0;
  let right = nums.length - 1;

  while (left <= right) {
    const mid = left + Math.floor((right - left) / 2);
    const num = nums[mid];

    if (target < num) {
      right = mid - 1;
    } else if (target > num) {
      left = mid + 1;
    } else {
      return mid;
    }
  }

  return left
}

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

力扣第 34 题

image.png 由于结果需要的是两个值,分别是左边界和右边界,因此要对左边界和右边界分别用二分搜索进行查找

本题的关键是数组**nums**是已经升序排序了,这样才能保证找到了左侧边界/右侧边界后保持不动,收缩另一侧直到跳出循环

  1. 多了找到值时对另一边不断收缩的过程
  2. 最终需要处理一下边界条件
    1. 数组越界
    2. 边界不等于target
/**
 * @description 相等时分别收缩左右两侧边界 直到触发边界条件退出
 */
function searchRange(nums: number[], target: number): number[] {
  return [leftBound(nums, target), rightBound(nums, target)];
}

/**
 * @description 获取左侧边界 -- 要收缩右侧边界
 */
function leftBound(nums: number[], target: number): number {
  let left = 0;
  let right = nums.length - 1;

  while (left <= right) {
    const mid = left + Math.floor((right - left) / 2);
    const num = nums[mid];

    if (target < num) {
      right = mid - 1;
    } else if (target > num) {
      left = mid + 1;
    } else {
      // 收缩右侧边界
      right = mid - 1;
    }
  }

  // 边界条件 -- 左侧边界越界 or 左侧边界不等于 target
  if (left >= nums.length || nums[left] !== target) return -1;

  return left;
}

/**
 * @description 获取右侧边界 -- 要收缩左侧边界
 */
function rightBound(nums: number[], target: number): number {
  let left = 0;
  let right = nums.length - 1;

  while (left <= right) {
    const mid = left + Math.floor((right - left) / 2);
    const num = nums[mid];

    if (target < num) {
      right = mid - 1;
    } else if (target > num) {
      left = mid + 1;
    } else {
      // 收缩左侧边界
      left = mid + 1;
    }
  }

  // 边界条件 -- 右侧边界越界 or 右侧边界值不等于 target
  if (right < 0 || nums[right] !== target) return -1;

  return right;
}

1.1.3 x的平方根

力扣第 69 题

image.pngx = 16为例,只要找出k^2 <= x的最大的k

每次取k = [0, x]的中间值,然后判断k^2 <= x

  • 小于等于的话说明k还可以更大,应当往右边搜索
  • 大于的话说明k太大了,要适当缩小,应当往左边搜索
function mySqrt(x: number): number {
  let left = 0;
  let right = x;
  let ans = -1;

  while (left <= right) {
    // 需要使用 左闭右闭 的边界 -- 因为 1^2 === 1
    const mid = left + Math.floor((right - left) / 2);
    const num = mid * mid;

    if (num <= x) {
      ans = mid;
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }

  return ans;
}

1.1.4 有效的完全平方数

力扣 367 题

image.png 和上一题类似,只是返回值从number变为了boolean

function isPerfectSquare(num: number): boolean {
  let left = 0;
  let right = num;

  while (left <= right) {
    const mid = left + Math.floor((right - left) / 2);
    const sqrt = mid * mid;

    if (sqrt < num) {
      left = mid + 1;
    } else if (sqrt > num) {
      right = mid - 1;
    } else {
      return true;
    }
  }

  return false;
}

1.2 移除元素

1.2.1 移除元素

力扣第 27 题

image.pngimage.png

1. 暴力

注意删除元素后,下标**i**后的元素都往前移动了一位,因此**i**也要相对前移一位

function removeElement(nums: number[], val: number): number {
  let size = nums.length;

  for (let i = 0; i < size; i++) {
    if (nums[i] === val) {
      for (let j = i + 1; j < size; j++) {
        nums[j - 1] = nums[j];
      }
      i--; // 因为下标i以后的数值都向前移动了一位,所以i也向前移动一位
      size--;
    }
  }

  return size;
}

2. 双指针

此方法不会破坏原数组顺序

function removeElement(nums: number[], val: number): number {
  let slow = 0;

  for (let fast = 0; fast < nums.length; fast++) {
    if (nums[fast] !== val) {
      nums[slow++] = nums[fast];
    }
  }

  return slow;
}

3. 相向双指针

  1. 找出左边等于 val
  2. 找出右边不等于 val
  3. 用右边的非 val 值替换左边的 val 值

此方法会破坏原数组顺序

function removeElement(nums: number[], val: number): number {
  let left = 0;
  let right = nums.length - 1;

  while (left <= right) {
    // 找出左边等于 val
    while (left <= right && nums[left] !== val) {
      left++;
    }

    // 找出右边不等于 val
    while (left <= right && nums[right] === val) {
      right--;
    }

    // 用右边的非 val 值替换左边的 val 值
    if (left < right) {
      nums[left++] = nums[right--];
    }
  }

  return left;
}

1.2.2 删除有序数组中的重复项

力扣第 26 题

image.pngimage.png

1. 暴力

function removeDuplicates(nums: number[]): number {
  let size = nums.length;

  for (let i = 0; i < size; i++) {
    for (let j = i + 1; j < size; j++) {
      if (nums[j] === nums[i]) {
        for (let k = j + 1; k < size; k++) {
          // 将 nums[j] 删除 -- j + 1 开始往前移动
          nums[k - 1] = nums[k];
        }
        j--; // j 之后的元素都向前移动了一位,因此 j 也相对要往前移动一位
        size--;
      }
    }
  }

  return size;
}

时间复杂度:O(N^3)


2. 双指针

  1. 慢指针指向的值和快指针指向的值相等时 --> 快指针前进
  2. 不相等时 --> 慢指针前进,快指针指向的值赋值给慢指针指向的值,快指针前进
/**
 * @description 双指针
 *
 * nums[slow] !== nums[fast] -> nums[++slow] = nums[fast]
 */
function removeDuplicates(nums: number[]): number {
  let slow = 0;

  for (let fast = 0; fast < nums.length; fast++) {
    if (nums[slow] !== nums[fast]) {
      nums[++slow] = nums[fast];
    }
  }

  return slow + 1;
}

1.2.3 移动零

力扣第 283 题

image.png

1. 暴力

双层遍历

  1. 遇到 0 时,先将 i 之后的元素往前移动一位
  2. 再将 0 移动到末尾
function moveZeroes(nums: number[]): void {
  let size = nums.length;

  for (let i = 0; i < size; i++) {
    if (nums[i] === 0) {
      // 1. 先将 i 之后的元素往前移动一位
      for (let j = i + 1; j < nums.length; j++) {
        nums[j - 1] = nums[j];
      }
      i--; // i 也要相对往前移动一位
      size--; // 将 0 看作无效元素,因此数组“长度”要减 1

      // 2. 再将 0 移动到末尾
      nums[nums.length - 1] = 0;
    }
  }
}

2. 双指针

  1. slow总是指向第一个0
  2. fast遇到非零,且在slow右边时 --> 交换
function moveZeroes(nums: number[]): void {
  let slow = 0;
  let fast = 0;

  // 初始化 slow -- 找到第一个 0
  while (slow < nums.length && nums[slow] !== 0) {
    slow++;
  }

  while (slow < nums.length && fast < nums.length) {
    if (nums[fast] !== 0 && slow < fast) {
      // fast 遇到非零元素且在 slow 右边时就和 slow 交换
      const temp = nums[fast];
      nums[fast] = nums[slow];
      nums[slow] = temp;

      // slow 移动到下一个非 0 元素
      while (slow < nums.length && nums[slow] !== 0) {
        slow++;
      }
    }
    fast++;
  }
}

3. 双指针 -- 复用移除元素

复用移除元素的代码,删除数组中的0,得到剩下的数组长度,然后将数组剩余部分赋值为0即可

function moveZeroes(nums: number[]): void {
  // 将 nums 的所有 0 移除,然后从 size 开始将数组剩余部分填充 0
  const size = removeElement(nums, 0);

  // 从 size 开始填充 0
  nums.fill(0, size);
}

/**
 * @description 移除 nums 数组中所有的 val 元素
 * @param nums 数组
 * @param val 待移除的元素
 * @returns 移除 val 后数组的长度
 */
function removeElement(nums: number[], val: number): number {
  let slow = 0;
  let fast = 0;

  while (fast < nums.length) {
    if (nums[fast] !== val) {
      nums[slow++] = nums[fast];
    }
    fast++;
  }

  return slow;
}

1.2.4 比较含退格的字符串

力扣 844 题

image.png

1. 两个栈

这是比较容易想到的一个办法,用两个栈分别存储st的最终结果,遇到非#则入栈,遇到#则出栈,最后比较即可

function backspaceCompare(s: string, t: string): boolean {
  // 用两个栈存储 s 和 t 的最终结果
  const sStack: string[] = [];
  const tStack: string[] = [];

  for (let i = 0; i < s.length; i++) {
    const char = s[i];

    if (char !== '#') {
      // 不是退格键则入栈
      sStack.push(char);
    } else {
      // 是退格键则出栈
      sStack.pop();
    }
  }

  // t 的处理也一样
  for (let i = 0; i < t.length; i++) {
    const char = t[i];

    if (char !== '#') {
      // 不是退格键则入栈
      tStack.push(char);
    } else {
      // 是退格键则出栈
      tStack.pop();
    }
  }

  // 对比两个栈是否相等
  if (sStack.length !== tStack.length) return false;
  for (let i = 0; i < sStack.length; i++) {
    if (sStack[i] !== tStack[i]) return false;
  }

  return true;
}

时间复杂度:O(N + M) 空间复杂度:O(N + M)


2. 双指针

从后往前遍历字符串,因为这样就能判断下一个遍历的字符是否有效,如果是从前往后遍历则无法做到

由于可能有多次连续的退格,因此用一个skip来统计退格的数量,遇到普通字符的时候,如果skip大于0,则当前遍历的字符视为无效,直接忽略

只有当skip0时,才会视为有效字符,这时候就要停下来,等另一边也拿到有效字符后,开始比较

function backspaceCompare(s: string, t: string): boolean {
  // 两个指针从尾开始遍历字符串
  let i = s.length - 1;
  let j = t.length - 1;

  // 统计退格数
  let sSkip = 0;
  let tSkip = 0;

  while (i >= 0 || j >= 0) {
    // 遍历 s -- 找到第一个有效的字符(即被退格符抵消后剩下的字符)
    while (i >= 0) {
      if (s[i] === '#') {
        // 统计退格符数量
        sSkip++;
        i--;
      } else if (sSkip > 0) {
        // skip 存在,说明当前的字符是无效的,因为被退格了
        sSkip--;
        i--;
      } else {
        // 找到了
        break;
      }
    }

    // 遍历 j -- 找到第一个有效的字符(即被退格符抵消后剩下的字符)
    while (j >= 0) {
      if (t[j] === '#') {
        // 统计退格符数量
        tSkip++;
        j--;
      } else if (tSkip > 0) {
        // skip 存在,说明当前的字符是无效的,因为被退格了
        tSkip--;
        j--;
      } else {
        // 找到了
        break;
      }
    }

    // 比较一下找到的有效字符是否相等
    if (i >= 0 && j >= 0) {
      // 找到了但是它们不相等 -- false
      if (s[i] !== t[j]) return false;
    } else if (i >= 0 || j >= 0) {
      // 两个字符串最终的长度不相等 -- false
      return false;
    }

    // 找到了且它们相等 -- 继续一起往前遍历
    i--;
    j--;
  }

  // 能通过 while 循环说明最终 s 和 t 是相等的
  return true;
}

1.2.5 有序数组的平方

力扣第 977 题

image.png

1. 双指针 -- 常规思路版本

当且仅当有正有负的时候需要用到双指针,如果是纯正数或纯负数直接全部加入res即可

有正有负时,要找出正负分界点,从分界点开始往两头遍历,每次遍历时比较大小,将小的加入到res

有一边先遍历完毕时,剩下的另一边直接全部平方加入到res即可

function sortedSquares(nums: number[]): number[] {
  // 找出正负分界点
  let cutOffPoint = -1;
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] < 0) cutOffPoint = i;
  }

  const res: number[] = [];
  if (cutOffPoint === -1) {
    // 全为正数 --> 直接平方加入到 res
    for (let i = 0; i < nums.length; i++) {
      res.push(Math.pow(nums[i], 2));
    }
  } else if (cutOffPoint === nums.length - 1) {
    // 全为负数 --> 逆序平方加入到 res
    for (let i = nums.length - 1; i >= 0; i--) {
      res.push(Math.pow(nums[i], 2));
    }
  } else {
    // 有正有负 --> 从分界点开始双指针向两头扩散遍历
    let i = cutOffPoint;
    let j = cutOffPoint + 1;

    while (i >= 0 && j < nums.length) {
      const positiveNum = Math.pow(nums[j], 2);
      const negativeNum = Math.pow(nums[i], 2);
      if (positiveNum < negativeNum) {
        // 正数小则正数先进去,并且可以继续前进
        res.push(positiveNum);
        j++;
      } else {
        // 负数小一些则负数进去,并且可以继续前进
        res.push(negativeNum);
        i--;
      }
    }

    while (i >= 0) {
      // 剩余的负数全部进去
      res.push(Math.pow(nums[i], 2));
      i--;
    }
    while (j < nums.length) {
      // 剩余的正数全部进去
      res.push(Math.pow(nums[j], 2));
      j++;
    }
  }

  return res;
}

2. 双指针 -- 更巧妙地版本

常规版本中还需要找到分界点进行比较,但是其实可以换一个角度

如果我们的双指针不是从分界点向两遍扩散,而是从两边向中间扩散呢?

对于nums数组:

  1. 正数这边从后往前,元素的平方是降序的
  2. 负数这边从前往后,元素的平方也是降序的

那么我们就可以考虑一下,从两边往中间遍历,那么无论是对于正数还是负数,都是降序的,由于结果是要升序的,那我们往res中存放数据时从后往前存放不就可以了吗?

function sortedSquares(nums: number[]): number[] {
  const res: number[] = new Array(nums.length);

  let i = 0;
  let j = nums.length - 1;
  let k = res.length - 1;

  while (i <= j) {
    const iPow2 = Math.pow(nums[i], 2);
    const jPow2 = Math.pow(nums[j], 2);

    if (iPow2 > jPow2) {
      res[k--] = iPow2;
      i++;
    } else {
      res[k--] = jPow2;
      j--;
    }
  }

  return res;
}

1.3 滑动窗口问题

1.3.1 长度最小的子数组

力扣第 209 题

image.png

1. 暴力

双层for循环,外层循环遍历数组,内层循环累加直到遇到sum >= target就判断更新res并跳出循环,让外层循环遍历下一个元素

function minSubArrayLen(target: number, nums: number[]): number {
  let res = Number.MAX_SAFE_INTEGER;

  for (let i = 0; i < nums.length; i++) {
    let sum = 0;
    for (let j = i; j < nums.length; j++) {
      sum += nums[j];
      if (sum >= target) {
        // 遇到第一个能够使子序列之和大于等于 target 的时候就要更新结果并跳出循环
        const subLength = j - i + 1;
        res = subLength < res ? subLength : res;
        break;
      }
    }
  }

  return res === Number.MAX_SAFE_INTEGER ? 0 : res;
}

2. 滑动窗口

关键点:

  1. 确定窗口中的元素有什么特点 -- 窗口内元素之和要大于等于**target**
  2. 窗口起始位置如何变化 -- 当窗口内元素之和大于等于**target**时,就要让窗口内元素减少,从而窗口起始位置应当右移(缩小窗口)
  3. 窗口结束位置如何变化 -- 当窗口内元素之和小于**target**时,就要让窗口内元素增加,从而窗口结束位置应当右移(增大窗口)

function minSubArrayLen(target: number, nums: number[]): number {
  // 初始化窗口起始和结束位置
  let left = 0;
  let right = 0;
  let sum = 0;
  let res = Number.MAX_SAFE_INTEGER;

  while (right < nums.length) {
    sum += nums[right];

    if (sum >= target) {
      while (sum - nums[left] >= target) {
        sum -= nums[left++];
      }

      res = Math.min(res, right - left + 1);
    }

    right++;
  }

  return res === Number.MAX_SAFE_INTEGER ? 0 : res;
}

1.3.2 水果成篮

力扣第 904 题

image.pngimage.png

滑动窗口

使用哈希表作为篮子,leftright作为窗口起始点,哈希表中的key是水果的种类,value是该种类的水果在篮子中出现的次数

窗口改变的时机:

  1. 缩小窗口 -- 窗口内水果种类超过 2 个
  2. 增大窗口 -- 窗口内水果种类小于等于 2 个
/**
 * 滑动窗口
 *
 * 使用哈希表充当篮子 key 是水果种类 value 是篮子中对应类型的水果的个数
 *
 * 窗口缩小时机 -- 窗口内水果种类超过 2 个
 * 窗口增大时机 -- 窗口内水果种类小于等于 2 个
 *
 */
function totalFruit(fruits: number[]): number {
  let left = 0;
  let res = 0;

  const basket = new Map(); // 篮子

  for (let right = 0; right < fruits.length; right++) {
    const currentFruit = fruits[right];
    // 将水果加入到篮子中
    const count = basket.get(currentFruit);
    if (!count) {
      basket.set(currentFruit, 1);
    } else {
      basket.set(currentFruit, basket.get(currentFruit) + 1);
    }

    // 加入到篮子中后如果水果种类超出 2 个就要开始缩小窗口
    while (basket.size > 2) {
      // 窗口起始处的水果种类对应的数量减 1 表示将其移出篮子
      basket.set(fruits[left], basket.get(fruits[left]) - 1);

      // 减到 0 了就说明篮子中已经没了这种水果了
      if (basket.get(fruits[left]) === 0) basket.delete(fruits[left]);

      // 缩小窗口
      left++;
    }

    // 更新 res
    res = Math.max(res, right - left + 1);
  }

  return res;
}

现在的代码太多底层Map的操作了,不太利于理解,可以封装一个Basket类,实现adddecrease操作,封装一些比较底层的逻辑

function totalFruit(fruits: number[]): number {
  let left = 0;
  let res = 0;

  const basket = new Basket(); // 篮子

  for (let right = 0; right < fruits.length; right++) {
    const currentFruit = fruits[right];
    // 将水果加入到篮子中
    basket.add(currentFruit);

    // 加入到篮子中后如果水果种类超出 2 个就要开始缩小窗口
    while (basket.size > 2) {
      // 窗口起始处的水果种类对应的数量减 1 表示将其移出篮子
      basket.decrease(fruits[left]);

      // 减到 0 了就说明篮子中已经没了这种水果了
      if (basket.get(fruits[left]) === 0) basket.delete(fruits[left]);

      // 缩小窗口
      left++;
    }

    // 更新 res
    res = Math.max(res, right - left + 1);
  }

  return res;
}

class Basket extends Map<number, number> {
  /**
   * @description 往篮子中放入水果
   * @param fruit 水果种类
   */
  add(fruit: number): void {
    const count = this.get(fruit);

    if (!count) {
      this.set(fruit, 1);
    } else {
      this.set(fruit, count + 1);
    }
  }

  /**
   * @description 将水果种类在篮子中的数量减 1
   * @param fruit 水果种类
   */
  decrease(fruit: number): void {
    const count = this.get(fruit);

    if (count) {
      this.set(fruit, count - 1);
    }
  }
}

1.3.3 最小覆盖子串

力扣第 76 题

image.png

滑动窗口

windowneed两个哈希表,记录窗口中的字符和需要凑齐的字符出现的次数 valid变量表示窗口中满足 need条件的字符个数,如果 validneed.size 的大小相同,则说明窗口已满足条件,已经完全覆盖了串 T

  1. 当移动 right扩大窗口,即加入字符时,应该更新哪些数据? -- **应该增加 ****window**计数器
  2. 什么条件下,窗口应该暂停扩大,开始移动 left缩小窗口? -- **当 ****valid****满足 ****need**时应该收缩窗口
  3. 当移动 left缩小窗口,即移出字符时,应该更新哪些数据? -- **应该减少 ****window**计数器
  4. 我们要的结果应该在扩大窗口时还是缩小窗口时进行更新? -- 应该在收缩窗口之前更新最终结果
/**
 * 滑动窗口
 *
 * need 统计 t 中字符出现的次数
 * window 统计窗口中包含 t 的字符的出现次数
 * e.g t --> abc  窗口中的字符串 --> asdfghj
 * need === { a: 1, b: 1, c: 1 }
 * window === { a: 1, b: 0, c: 0 }
 *
 * valid 代表有效字符的个数
 * e.g t --> abc 窗口中的字符串 --> asdfghj
 * valid === 1 --> 因为当前窗口中只有 a 符合要求
 */
function minWindow(s: string, t: string): string {
  let left = 0;
  let right = 0;
  let valid = 0;
  let start = 0; // 最终结果字符串在 s 中的起始下标
  let len = Number.MAX_SAFE_INTEGER; // 最终结果字符串的长度
  const window = new Map<string, number>();
  const need = new Map<string, number>();

  // 初始化 need
  for (const char of t) {
    const oldVal = need.has(char) ? need.get(char) : 0;
    need.set(char, oldVal! + 1);
  }

  // 滑动窗口
  while (right < s.length) {
    const c = s[right]; // c 是即将进入窗口的字符
    // 增大窗口 -- 寻找可行解
    right++;
    // 进行窗口内数据的更新 -- 更新 window 哈希表
    if (need.has(c)) {
      // 如果 c 是 need 中需要的 则将其加入到 window 中 (已有则将出现次数加 1)
      const oldVal = window.has(c) ? window.get(c) : 0;
      window.set(c, oldVal! + 1);
      // 判断增加后 c 在 window 中出现的次数是否满足 need 中对应的需求 是的话则让 valid++
      if (window.get(c) === need.get(c)) {
        valid++;
      }
    }

    // =============== 在这里进行 debug ===============

    // 判断左侧窗口是否需要收缩
    while (valid === need.size) {
      // 寻找最优解
      if (right - left < len) {
        start = left;
        len = right - left;
      }
      const d = s[left]; // d 是即将移出窗口的字符
      // 收缩窗口 -- 在收缩之前寻找最优解
      left++;

      // 进行窗口内数据的更新 -- 更新 window 哈希表
      if (need.has(d)) {
        // 如果 d 是 need 中需要的 则将其在 window 中的出现次数减 1

        // 如果 d 在窗口中出现的次数和在 need 中需要的次数一样 那么 valid 应该减 1
        // 因为移出窗口后 d 在 window 中出现的次数会减 1 这就导致原本满足 valid 的变为了不满足
        if (window.get(d) === need.get(d)) {
          valid--;
        }

        // d 在 window 中的出现次数减 1
        window.set(d, window.get(d)! - 1); // 用 ! 断言存在是因为右侧窗口遍历的时候肯定已经将 d 添加到窗口了
      }
    }
  }

  return len === Number.MAX_SAFE_INTEGER ? '' : s.slice(start, start + len);
}

这里无论是对need的初始化,还是对window的修改,都是可以用“计数器”的思想去实现的,这样整体代码逻辑会更加清晰,可以用我们总结当中的Counter去进一步简化代码逻辑

function minWindow(s: string, t: string): string {
  let left = 0;
  let right = 0;
  let valid = 0;
  let start = 0; // 最终结果字符串在 s 中的起始下标
  let len = Number.MAX_SAFE_INTEGER; // 最终结果字符串的长度
  const window = new Counter<string>();
  const need = new Counter<string>();

  // 初始化 need
  for (const char of t) {
    need.count(char);
  }

  // 滑动窗口
  while (right < s.length) {
    const c = s[right]; // c 是即将进入窗口的字符
    // 增大窗口 -- 寻找可行解
    right++;
    // 进行窗口内数据的更新 -- 更新 window 哈希表
    if (need.has(c)) {
      // 如果 c 是 need 中需要的 则将其加入到 window 中 (已有则将出现次数加 1)
      window.count(c);
      // 判断增加后 c 在 window 中出现的次数是否满足 need 中对应的需求 是的话则让 valid++
      if (window.get(c) === need.get(c)) {
        valid++;
      }
    }

    // =============== 在这里进行 debug ===============

    // 判断左侧窗口是否需要收缩
    while (valid === need.size) {
      // 寻找最优解
      if (right - left < len) {
        start = left;
        len = right - left;
      }
      const d = s[left]; // d 是即将移出窗口的字符
      // 收缩窗口 -- 在收缩之前寻找最优解
      left++;

      // 进行窗口内数据的更新 -- 更新 window 哈希表
      if (need.has(d)) {
        // 如果 d 是 need 中需要的 则将其在 window 中的出现次数减 1

        // 如果 d 在窗口中出现的次数和在 need 中需要的次数一样 那么 valid 应该减 1
        // 因为移出窗口后 d 在 window 中出现的次数会减 1 这就导致原本满足 valid 的变为了不满足
        if (window.get(d) === need.get(d)) {
          valid--;
        }

        // d 在 window 中的出现次数减 1
        window.decrease(d);
      }
    }
  }

  return len === Number.MAX_SAFE_INTEGER ? '' : s.slice(start, start + len);
}

1.3.4 字符串的排列

力扣第 564 题

image.png

滑动窗口

整体和最小覆盖子串差不多,区别在于下面两个地方:

  1. 窗口缩小的时机需要改为窗口长度**>=**目标字符串长度时,包含等于的情况是为了能够进入循环判断结果
  2. 不需要记录结果相关信息,遇到valid === need.size时就可以直接return true
class Counter<K = any> extends Map<K, number> {
  count(key: K) {
    if (this.has(key)) this.set(key, this.get(key)! + 1);
    else this.set(key, 1);
  }

  decrease(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! - 1);
    }
  }
}

function checkInclusion(s1: string, s2: string): boolean {
  let left = 0;
  let right = 0;
  let valid = 0;
  const window = new Counter();
  const need = new Counter();

  // 初始化 need -- 使用 s1
  for (const char of s1) {
    need.count(char);
  }

  while (right < s2.length) {
    const c = s2[right];
    right++;

    if (need.has(c)) {
      window.count(c);

      if (window.get(c) === need.get(c)) {
        valid++;
      }
    }

    // 缩小窗口的时机 -- 窗口长度 >= s1.length -- 包括等于的情况是为了判断结果
    // 这样才能保证窗口内不包含 s1 以外的其他字符
    while (right - left >= s1.length) {
      if (valid === need.size) {
        return true;
      }

      const d = s2[left];
      left++;

      if (need.has(d)) {
        if (window.get(d) === need.get(d)) {
          valid--;
        }
        window.decrease(d);
      }
    }
  }

  return false;
}

1.3.5 找到字符串中所有字母异位词

力扣第 438 题

image.png

滑动窗口

和上一道题一模一样,只是判断结果的时候变为了将答案加入到res

class Counter<K = any> extends Map<K, number> {
  count(key: K) {
    if (this.has(key)) this.set(key, this.get(key)! + 1);
    else this.set(key, 1);
  }

  decrease(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! - 1);
    }
  }
}

function findAnagrams(s: string, p: string): number[] {
  let left = 0;
  let right = 0;
  let valid = 0;
  const res: number[] = [];
  const window = new Counter();
  const need = new Counter();

  // 初始化 need
  for (const char of p) {
    need.count(char);
  }

  while (right < s.length) {
    const c = s[right];
    right++;

    if (need.has(c)) {
      window.count(c);

      if (window.get(c) === need.get(c)) valid++;
    }

    while (right - left >= p.length) {
      if (valid === need.size) {
        res.push(left);
      }

      const d = s[left];
      left++;

      if (need.has(d)) {
        if (window.get(d) === need.get(d)) valid--;
        window.decrease(d);
      }
    }
  }

  return res;
}

1.3.6 无重复字符的最长子串

力扣第 3 题

image.png

滑动窗口

关键点:

  1. 收缩窗口的时机 -- 出现重复字符的时候
  2. 更新结果的时机 -- 窗口收缩完成之后,因为收缩窗口之前窗口内是包含重复字符的
class Counter<K = any> extends Map<K, number> {
  count(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! + 1);
    } else {
      this.set(key, 1);
    }
  }

  decrease(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! - 1);
    }
  }
}

function lengthOfLongestSubstring(s: string): number {
  let left = 0;
  let right = 0;
  let res = 0;
  const window = new Counter();

  while (right < s.length) {
    const c = s[right];
    right++;

    window.count(c);

    // 出现重复字符的时候就收缩窗口
    while (window.get(c)! > 1) {
      // 出现重复字符 -- 缩小窗口
      const d = s[left];
      left++;
      window.decrease(d);
    }

    // 要在窗口收缩完后才更新 res 这样才能保证窗口内没有重复字符
    res = Math.max(res, right - left);
  }

  return res === 0 ? s.length : res;
}

1.4 螺旋矩阵问题

1.4.1 生成螺旋矩阵

力扣第 59 题

image.png 关键点:

  1. 用四个边界变量控制填充遍历条件
function generateMatrix(n: number): number[][] {
  // 初始化结果矩阵
  const res: number[][] = new Array(n);
  for (let i = 0; i < n; i++) {
    res[i] = new Array(n).fill(0);
  }

  // 填入矩阵中的数字
  let num = 1;
  // 四个边界
  let topBound = 0;
  let rightBound = n - 1;
  let bottomBound = n - 1;
  let leftBound = 0;

  while (num <= n * n) {
    // 上边界从左往右填充
    if (topBound <= bottomBound) {
      for (let j = leftBound; j <= rightBound; j++) {
        res[topBound][j] = num++;
      }

      topBound++; // 上边界下移
    }

    // 右边界从上往下填充
    if (rightBound >= leftBound) {
      for (let i = topBound; i <= bottomBound; i++) {
        res[i][rightBound] = num++;
      }

      rightBound--; // 右边界左移
    }

    // 下边界从右往左填充
    if (bottomBound >= topBound) {
      for (let j = rightBound; j >= leftBound; j--) {
        res[bottomBound][j] = num++;
      }

      bottomBound--; // 下边界上移
    }

    // 左边界从下往上填充
    if (leftBound <= rightBound) {
      for (let i = bottomBound; i >= topBound; i--) {
        res[i][leftBound] = num++;
      }

      leftBound++; // 左边界右移
    }
  }

  return res;
}

1.4.2 遍历螺旋矩阵

力扣第 54 题

image.pngimage.png 和上一题一样

function spiralOrder(matrix: number[][]): number[] {
  const m = matrix.length;
  const n = matrix[0].length;
  const res: number[] = [];
  let topBound = 0;
  let rightBound = n - 1;
  let bottomBound = m - 1;
  let leftBound = 0;

  while (res.length < m * n) {
    // 上边界从左往右遍历
    if (topBound <= bottomBound) {
      for (let j = leftBound; j <= rightBound; j++) {
        res.push(matrix[topBound][j]);
      }

      topBound++; // 上边界下移
    }

    // 右边界从上往下遍历
    if (rightBound >= leftBound) {
      for (let i = topBound; i <= bottomBound; i++) {
        res.push(matrix[i][rightBound]);
      }

      rightBound--; // 右边界左移
    }

    // 下边界从右往左遍历
    if (bottomBound >= topBound) {
      for (let j = rightBound; j >= leftBound; j--) {
        res.push(matrix[bottomBound][j]);
      }

      bottomBound--; // 下边界上移
    }

    // 左边界从下往上遍历
    if (leftBound <= rightBound) {
      for (let i = bottomBound; i >= topBound; i--) {
        res.push(matrix[i][leftBound]);
      }

      leftBound++; // 左边界右移
    }
  }

  return res;
}

1.4.3 顺时针旋转矩阵

力扣第 48 题

image.pngimage.png 关键点:

  1. 沿着主对角线(左上到右下)镜像翻转 -- 即交换**matrix[i][j] <==> matrix[j][i]**
  2. 将矩阵的每一行逆序

逆时针的话则只需要沿着副对角线(右上到左下)镜像翻转

function rotate(matrix: number[][]): void {
  const m = matrix.length;
  const n = matrix[0].length;

  // 1. 沿着主对角线镜像翻转矩阵
  for (let i = 0; i < m; i++) {
    for (let j = i; j < n; j++) {
      // 交换 matrix[i][j]
      const temp = matrix[i][j];
      matrix[i][j] = matrix[j][i];
      matrix[j][i] = temp;
    }
  }

  // 2. 逆序每一行
  for (let i = 0; i < m; i++) {
    reverse(matrix[i]);
  }
}

/**
 * @description 逆序一个数组
 * @param nums 数组
 */
function reverse(nums: number[]): void {
  let i = 0;
  let j = nums.length - 1;

  while (i < j) {
    const temp = nums[i];
    nums[i] = nums[j];
    nums[j] = temp;

    i++;
    j--;
  }
}

2. 哈希表

总结

何时使用哈希表:需要快速判断一个元素是否出现在集合中时


2.1 有效的字母异位词

力扣第 242 题

image.png 关键点:

  1. 使用一个计数器哈希表counter统计s中每个字符的出现次数
  2. 遍历t去让counter中相应字符出现次数减少1,如果counter中不存在该字符则初始值为-1
class Counter<K = any> extends Map<K, number> {
  count(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! + 1);
    } else {
      this.set(key, 1);
    }
  }

  decrease(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! - 1);
    } else {
      this.set(key, -1);
    }
  }
}

function isAnagram(s: string, t: string): boolean {
  // 长度不相等时 不可能是字母异位词
  if (s.length !== t.length) return false;

  const counter = new Counter();

  // 统计 s 的字符出现次数
  for (let i = 0; i < s.length; i++) {
    counter.count(s[i]);
  }

  // 抵消 s 的字符出现次数
  for (let i = 0; i < t.length; i++) {
    counter.decrease(t[i]);
  }

  // 只要存在一个不为 0 的 --> 就说明不是字母异位词
  for (const count of counter.values()) {
    if (count !== 0) return false;
  }

  return true;
}

2.2 赎金信

力扣第 383 题

image.png 关键点:

  1. 用两个哈希表计数器分别统计ransomNotemagazine中的字符出现次数
  2. 遍历ransomNote中的keys
    1. 如果存在任何一个keymagazine中没有的,则不符合
    2. 如果任何一个keymagazine中的出现次数小于ransomNote中的出现次数也不符合
class Counter<K = any> extends Map<K, number> {
  count(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! + 1);
    } else {
      this.set(key, 1);
    }
  }

  decrease(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! - 1);
    } else {
      this.set(key, -1);
    }
  }
}

function canConstruct(ransomNote: string, magazine: string): boolean {
  const ransomNoteCounter = new Counter();
  const magazineCounter = new Counter();

  for (let i = 0; i < ransomNote.length; i++) {
    ransomNoteCounter.count(ransomNote[i]);
  }

  for (let i = 0; i < magazine.length; i++) {
    magazineCounter.count(magazine[i]);
  }

  for (const key of ransomNoteCounter.keys()) {
    if (!magazineCounter.has(key)) return false;
    if (magazineCounter.get(key)! < ransomNoteCounter.get(key)!) return false;
  }

  return true;
}

2.3 字母异位词分组

力扣第 49 题

image.png 关键点:

  1. 将字符串编码,一般根据字符出现的次数进行编码
/**
 * @description 根据每个字符的出现次数进行编码
 * @param str 字符串
 */
function encode(str: string): string {
  // 存放每个字符出现次数的哈希表 -- 用数组实现
  const code = new Array<number>(26).fill(0);

  for (const char of str) {
    // 获取字符对应的数字编码值
    const codeIndex = char.charCodeAt(0) - 'a'.charCodeAt(0);

    // 给 char 计数 -- key 为 codeIndex
    code[codeIndex]++;
  }

  return code.toString();
}

编码是为了方便作为哈希表的key

  1. 以编码结果code作为keyvalue是具有相同code编码结果的字符串构成的数组
function groupAnagrams(strs: string[]): string[][] {
  // 编码到分组的映射
  // strs 中的每个字符串的编码结果作为 key,value 是具有相同 key 的字符串组成的数组
  const codeToGroup = new Map<string, string[]>();

  // 对 strs 中的每个字符串进行编码
  for (const str of strs) {
    // 编码得到的 code 作为 key
    const code = encode(str);

    // 把 code 相同的 str 放到一起
    if (!codeToGroup.has(code)) {
      codeToGroup.set(code, [str]);
    } else {
      codeToGroup.get(code)?.push(str);
    }
  }

  // 生成结果
  const res: string[][] = [];
  for (const group of codeToGroup.values()) {
    res.push(group);
  }

  return res;
}

2.4 两个数组的交集

力扣第 349 题

image.png 关键点:

  1. 把其中一个数组转成Set
  2. 遍历另一个数组,如果在Set中则将其加入到结果集
function intersection(nums1: number[], nums2: number[]): number[] {
  // 把 nums1 转成 set
  const nums1Set = new Set(nums1);
  const res = new Set<number>();

  // 遍历 nums2 遇到存在于 nums1Set 中则加入到结果集
  for (let i = 0; i < nums2.length; i++) {
    const num = nums2[i];

    if (nums1Set.has(num)) res.add(num);
  }

  return Array.from(res.values());
}

2.5 两个数组的交集Ⅱ

力扣第 350 题

image.png 关键点:

  1. 遍历较短的数组,统计每个字符的出现次数
  2. 遍历较长的数组,num在计数器中存在且不为0则加入到结果集,并让对应的计数器值减1
class Counter<K = any> extends Map<K, number> {
  count(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! + 1);
    } else {
      this.set(key, 1);
    }
  }

  decrease(key: K) {
    if (this.has(key)) {
      this.set(key, this.get(key)! - 1);
    }
  }
}

function intersect(nums1: number[], nums2: number[]): number[] {
  // 让较小的数组作为 nums1
  if (nums1.length > nums2.length) {
    return intersect(nums2, nums1);
  }

  const counter = new Counter<number>();
  const res: number[] = [];

  // 统计 nums1 中的字符出现次数
  for (let i = 0; i < nums1.length; i++) {
    counter.count(nums1[i]);
  }

  // nums2 中的字符如果有在 counter 中则将其加入到结果集 并让相应计数减 1
  for (let i = 0; i < nums2.length; i++) {
    const num = nums2[i];

    if (counter.get(num)) {
      res.push(num);
      counter.decrease(num);
    }
  }

  return res;
}

3. 动态规划

总结

一定要按照动规五部曲去思考:

  1. 明确dp数组的含义
  2. 明确递推公式
  3. 明确dp数组如何初始化
  4. 明确遍历顺序
  5. 举例推导dp数组

3.1 斐波那契数

力扣第 509 题

image.png 关键点:

  1. 明确dp数组含义:dp[i]表示第i个斐波那契数是多少
  2. 明确递推公式:dp[i] = dp[i-1] + dp[i-2]
  3. 明确dp数组如何初始化:dp[0] = 0``dp[1] = 1
  4. 明确遍历顺序:每一项都依赖于前两项,因此是从前往后遍历
  5. 举例推导dp数组:在草稿纸上列举前10dp值 -- 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55
function fib(n: number): number {
  if (n <= 1) return n;

  // 1. dp 数组 -- dp[i] 表示第 i 个数的 斐波那契数值
  const dp: number[] = new Array(n + 1);

  // 2. 递推公式 -- dp[i] = dp[i-1] + dp[i-2]

  // 3. dp 数组初始化 dp[0] = 0 dp[1] = 1
  dp[0] = 0;
  dp[1] = 1;

  // 4. 遍历顺序 -- 从前往后
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }

  return dp[n];
}

由于每一项都只和前两项有关系,因此可以进行空间优化,将dp数组优化成两个指针

function fib(n: number): number {
  if (n <= 1) return n;

  // 1. dp 数组 -- dp[i] 表示第 i 个数的 斐波那契数值

  // 2. 递推公式 -- dp[i] = dp[i-1] + dp[i-2]

  // 3. dp 数组初始化 dp[0] = 0 dp[1] = 1
  let dp0 = 0;
  let dp1 = 1;

  // 4. 遍历顺序 -- 从前往后
  for (let i = 2; i <= n; i++) {
    // dp2 = dp1 + dp0 = 1 + 0
    // dp3 = dp2 + dp1 = 1 + 1 => dp1 -> dp2 | dp0 -> dp1
    // dp4 = dp3 + dp2 = 2 + 1 => dp1 -> dp3 | dp0 -> dp2
    // 即把 dpi 看成 dp1 + dp0
    // dp1 每次都是 dp1 与 dp0 之和
    // dp0 则变为 dp1
    const sum = dp1 + dp0;
    dp0 = dp1;
    dp1 = sum;
  }

  return dp1;
}

3.2 爬楼梯

力扣第 70 题

image.png 关键点:

  1. 明确 dp 数组含义 -- dp[i] 表示爬到第 i 阶有 dp[i] 种方法
  2. 明确递推公式 -- dp[i] = dp[i-1] + dp[i-2]
    • 上 i-1 阶楼梯 有 dp[i-1] 种方法 站在第 i-1 阶楼梯上,再上一阶就是第 i 阶了
    • 上 i-2 阶楼梯 有 dp[i-2] 种方法 站在第 i-2 阶楼梯上,再上两阶就是第 i 阶了
    • 注意:在第 i-2 阶楼梯上时,不能一次只上 1 阶楼梯,如果只上 1 阶,就到了 i-1 阶,那又和上 i-1 阶楼梯的情况重复了
  3. 明确 dp 数组如何初始化
    • dp[1] = 1 -- 直接上 1 阶
    • dp[2] = 2 -- 直接上 2 阶 or 一次上 1 阶,一共上两次
  4. 明确遍历顺序
    • 要知道第 i 阶有几种方法,需要先知道第 i-1 和 i-2 阶
    • 因此需要从前往后遍历
  5. 举例推导 dp 数组 -- 列出前 5 项:1 2 3 5 8
function climbStairs(n: number): number {
  // 1. 明确 dp 数组含义 -- dp[i] 表示爬到第 i 阶有 dp[i] 种方法
  /**
   * 2. 明确递推公式 -- dp[i] = dp[i-1] + dp[i-2]
   * 上 i-1 阶楼梯 有 dp[i-1] 种方法 站在第 i-1 阶楼梯上,再上一阶就是第 i 阶了
   * 上 i-2 阶楼梯 有 dp[i-2] 种方法 站在第 i-2 阶楼梯上,再上两阶就是第 i 阶了
   * 注意:在第 i-2 阶楼梯上时,不能一次只上 1 阶楼梯,如果只上 1 阶,就到了 i-1 阶
   *      那又和上 i-1 阶楼梯的情况重复了
   */
  /**
   * 3. 明确 dp 数组如何初始化
   *
   * dp[1] = 1 -- 直接上 1 阶
   * dp[2] = 2 -- 直接上 2 阶 or 一次上 1 阶,一共上两次
   */
  /**
   * 4. 明确遍历顺序
   *
   * 要知道第 i 阶有几种方法,需要先知道第 i-1 和 i-2 阶
   * 因此需要从前往后遍历
   */
  /**
   * 5. 举例推导 dp 数组 -- 列出前 5 项
   *
   * 1 2 3 5 8
   */
  if (n <= 2) return n;

  const dp: number[] = new Array(n + 1);
  dp[1] = 1;
  dp[2] = 2;

  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }

  return dp[n];
}

和斐波那契数一样,因为递推公式只涉及到前两个的状态,因此可以进行空间优化

function climbStairs(n: number): number {
  if (n <= 2) return n;

  let dp1 = 1;
  let dp2 = 2;

  for (let i = 3; i <= n; i++) {
    // dp3 = dp2 + dp1
    // dp4 = dp3 + dp2 => dp2 -> dp3 | dp1 -> dp2
    const sum = dp1 + dp2;
    dp1 = dp2;
    dp2 = sum;
  }

  return dp2;
}

3.3 使用最小花费爬楼梯

力扣第 746 题

image.pngimage.png 关键点:

  1. 明确 dp 数组的含义 -- dp[i] 表示爬到第 i 阶需要支付的最小费用
  2. 明确递推公式 -- dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
  3. 明确如何初始化 dp数组 -- dp[0] = 0 | dp[1] = 0
  4. 明确遍历顺序 -- 依赖于前两项 因此从前往后遍历
  5. 举例 -- cost = [10, 15, 20] --dp === [0, 0, 10, 15]

递推公式的意思是爬到第**i**阶的花费来源有两个:

  1. 从第**i-1**阶爬一阶上来,需要花费爬到第**i-1**阶的最小花费加上从第**i-1**阶爬上来的花费**cost[i-1]**
  2. 从第**i-2**阶爬两阶上来,需要花费爬到第**i-2**阶的最小花费加上从第**i-2**阶爬上来的花费**cost[i-2]**

由于要求最小花费,因此要取这两个来源的最小值

**dp**这样初始化是因为题目说从第**0**或第**1**个台阶开始爬,那么爬到第**0**或者第**1**个台阶是不需要交钱的,开始爬上**1/2**阶时才需要交钱,因此初始化为**0**

function minCostClimbingStairs(cost: number[]): number {
  // 1. 明确 dp 数组的含义 -- dp[i] 表示爬到第 i 阶需要支付的最小费用
  // 2. 明确递推公式 -- dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
  // 3. 明确如何初始化 dp 数组 -- dp[0] = 0 | dp[1] = 0
  // 4. 明确遍历顺序 -- 依赖于前两项 因此从前往后遍历
  /**
   * 5. 举例 -- cost = [10, 15, 20]
   *
   * dp === [0, 0, 10, 15]
   */

  // 1. 明确 dp 数组的含义 -- dp[i] 表示爬到第 i 阶需要支付的最小费用
  const dp: number[] = new Array(cost.length + 1);

  // 2. 明确递推公式 -- dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])

  // 3. 明确如何初始化 dp 数组 -- dp[0] = 0 | dp[1] = 0
  dp[0] = 0;
  dp[1] = 0;

  // 4. 明确遍历顺序 -- 依赖于前两项 因此从前往后遍历
  for (let i = 2; i < dp.length; i++) {
    dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
  }

  // 楼顶是指第 cost.length 阶 也就是 dp 数组的最后一项就是爬到楼顶的最低花费
  return dp[dp.length - 1];
}

还是老样子,因为递推公式只和前两个状态有关系,因此可以进行空间优化

function minCostClimbingStairs(cost: number[]): number {
  // 1. 明确 dp 数组的含义 -- dp[i] 表示爬到第 i 阶需要支付的最小费用

  // 2. 明确递推公式 -- dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])

  // 3. 明确如何初始化 dp 数组 -- dp[0] = 0 | dp[1] = 0
  let dp0 = 0;
  let dp1 = 0;

  // 4. 明确遍历顺序 -- 依赖于前两项 因此从前往后遍历
  for (let i = 2; i <= cost.length; i++) {
    const dpi = Math.min(dp1 + cost[i - 1], dp0 + cost[i - 2]);
    dp0 = dp1;
    dp1 = dpi;
  }

  // 楼顶是指第 cost.length 阶 也就是 dp 数组的最后一项就是爬到楼顶的最低花费
  return dp1;
}

3.4 不同路径

力扣第 62 题

image.pngimage.png 关键点:

  1. dp[i][j] -- 到第 (i, j) 格有多少条不同的路径
  2. dp[i][j] = (dp[i-1][j] + 1) + (dp[i][j-1] + 1) (i, j 不同时为 0)
  3. dp[0][0] = 0
  • 第一行和第一列的任何格子都只有一条路径
    • dp[0][j] = 1
    • dp[i][0] = 1
  • 常规格子
    • dp[1][1] = dp[0][1] + dp[1][0]
    • dp[i][j] = dp[i-1][j] +dp[i][j-1]
  1. 从左上到右下逐行遍历

dp数组的初始化可以进一步简化,将第一行和第一列的dp值初始化为1

function uniquePaths(m: number, n: number): number {
  // 1. dp[i][j] -- 到第 (i, j) 格有多少条不同的路径
  const dp: number[][] = new Array(m);
  for (let i = 0; i < dp.length; i++) {
    dp[i] = new Array(n);
  }

  // 2. dp[i][j] = (dp[i-1][j] + 1) + (dp[i][j-1] + 1) (i, j 不同时为 0)

  /**
   * 3. dp[0][0] = 0
   *
   *    第一行和第一列的任何格子都只有一条路径
   *    dp[0][1] = 1
   *    dp[1][0] = 1
   *
   *    常规格子
   *    dp[1][1] = dp[0][1] + dp[1][0]
   *    ...
   */

  // 第一行和第一列初始化为 1
  for (let i = 0; i < m; i++) dp[i][0] = 1;
  for (let j = 0; j < n; j++) dp[0][j] = 1;

  // 4. 从左上到右下逐行遍历
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
  }

  // 5. 最终返回 dp[m-1][n-1]
  return dp[m - 1][n - 1];
}

3.5 不同路径Ⅱ

力扣第 63 题

image.pngimage.png 关键点:

  1. dp数组的含义:从(0,0)(i,j)dp[i][j]种不同的路径
  2. 状态转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  3. 初始化dp数组:第1行和第1列初始化为1,但是如果遇到障碍物,则为0,并且后续元素均初始化为0
  4. 遍历方式:从左往右逐行遍历

和上一题相比,不同的地方有两点:

  1. dp数组的初始化方式不同了,一旦遇到障碍物,则后续的初始化都是置为0
  2. 状态转移的时候,遇到障碍物则直接将dp[i][j]设置为0,表示不可到达
function uniquePathsWithObstacles(obstacleGrid: number[][]): number {
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;
  const dp: number[][] = new Array(m);
  for (let i = 0; i < dp.length; i++) {
    dp[i] = new Array(n);
  }

  // 初始化 dp 数组
  let hasObstacle = false; // 是否有障碍物
  for (let i = 0; i < m; i++) {
    if (obstacleGrid[i][0] === 1) {
      // 判断是否遇到障碍物 -- 有障碍物时将标志变量置为 true
      hasObstacle = true;
    }
    // 初始化第一列为 1
    if (!hasObstacle) dp[i][0] = 1;
    // 有障碍物时 障碍物所在格子包括之后的都是不可达的,均初始化为 0
    else dp[i][0] = 0;
  }

  hasObstacle = false; // 重置标志变量
  for (let j = 0; j < n; j++) {
    if (obstacleGrid[0][j] === 1) {
      // 判断是否遇到障碍物 -- 有障碍物时将标志变量置为 true
      hasObstacle = true;
    }
    // 初始化第一行为 1
    if (!hasObstacle) dp[0][j] = 1;
    // 有障碍物时 障碍物所在格子包括之后的都是不可达的,均初始化为 0
    else dp[0][j] = 0;
  }

  // 从左往右逐行遍历
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      if (obstacleGrid[i][j] === 1) {
        // 遇到障碍物 -- dp[i][j] 设置为 0 表示不可达
        dp[i][j] = 0;
      } else {
        // 无障碍物 -- 正常转移
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
      }
    }
  }

  console.log(dp);

  return dp[m - 1][n - 1];
}