数据结构与算法之常见算法思想(十)

1,548 阅读5分钟

分而治之

  • 分而治之是算法设计中的一种方法

  • 它将一个问题成多个和原问题相似的小问题,递归解决小问题,再将结果并以解决原来的问题

场景一:归并排序

  • 分:把数组从中间一分为二
  • 解:递归地对两个子数组进行归并排序
  • 合:合并有序子数组

场景二:快速排序

  • 分:选基准,按基准把数组分成两个子数组
  • 解:递归地对两个子数组进行快速排序
  • 合:对两个子数组进行合并

LeetCode: 374. 猜数字大小

解题思路:

  • 二分搜索,同样具备 **“分、解、合〞**的特性
  • 考虑选择分而治之

解题步骤:

  • 分:计算中间元素,分割数组。
  • 解:递归地在较大或者较小子数组进行二分搜索
  • 合:不需要此步,因为在子数组中搜到就返回了
// 时间复杂度:O(logN)
// 空间复杂度:O(logN)

const guessNumber = function(n) {
  const rec = (low, high) => {
    if(low > high) return;

    const mid = Math.floor((low + high) / 2);
    const res = guess(mid);
    if(res === 0) {
      return mid;
    }else if(res === 1) {
      return rec(mid + 1, high);
    }else {
      return rec(1, mid - 1);
    }
  }
  return rec(1, n)
};

LeetCode: 226.翻转二叉树

  • 先翻转左右子树,再将子树换个位置
  • 符合 **“分、解、合〞**特性
  • 考虑选择分而治之

解题步骤:

  • 分:获取左右子树
  • 解:递归地翻转左右子树
  • 合:将翻转后的左右子树换个位置放到根节点上
// 时间复杂度 O(n) n为树的节点数量
// 空间复杂度 O(h) h为树的高度

const invertTree = function(root) {
  if(!root) return null;

  return {
    val: root.val,
    left: invertTree(root.right),
    right: invertTree(root.left)
  }
};

LeetCode: 100.相同的树

解题思路:

  • 两个树:根节点的值相同,左子树相同,右子树相同
  • 符合 **“分、解、合〞**特性
  • 考虑选择分而治之

解题步骤:

  • 分:获取两个树的左子树和右子树
  • 解:递归地判断两个树的左子树是否相同,右子树是否相同
  • 合:将上述结果合并,如果根节点的值也相同,树就相同
// 时间复杂度:O(n) n为树的节点数量
// 空间复杂度:O(h) h为树的节点数

const isSameTree = function (p, q) {
  if (!p && !q) return true;

  if (p && q && p.val === q.val && isSameTree(p.left, q.left) && isSameTree(p.right, q.right)) {
    return true;
  }
  return false;
};

LeetCode: 101. 对称二叉树

解题思路:

  • 转化为:左右子树是否镜像
  • 分解为:树1的左子树和树2的右子树是否镜像,树1的右子树和树2的左子树是否镜像
  • 符合 **“分、解、合〞**特性,考虑选择分而治之

解题步骤:

  • 分:获取两个树的左子树和右子树
  • 解:递归地判断树1的左子树和树2的右子树是否镜像,树1的右子树和树2的左子树是否镜像
  • 合:如果上述都成立,且根节点值也相同,两个树就镜像
// 时间复杂度 O(n)
// 空间复杂度 O(n)

const isSymmetric = function (root) {
  if (!root) return true;

  const isMirror = (l, r) => {
    if (!l && !r) return true;
    if (l && r && l.val === r.val && isMirror(l.left, r.right) && isMirror(l.right, r.left)) {
      return true;
    }
    return false;
  };

  return isMirror(root.left, root.right);
};

动态规划

  • 动态规划是算法设计中的一种方法
  • 它将一个问题分解为相互重叠的子问题,通过反复求解子问题,来解决原来的问题

斐波那契数列

// 时间复杂度 O(n) 
// 空间复杂度 O(n)

function fib(n) {
    let dp = [0, 1, 1];
    for (let i = 3; i <= n; i++) {

        // 当前值等于前两个值之和
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}

LeetCode: 70.爬楼梯

解题思路:

  • 爬到第 n 阶可以在第 n-1 阶爬 1 个台阶,或者在第 n-2 阶爬 2 个台阶
  • F(n) = F(n-1) + F(n-2)
  • 使用动态规划

解题步骤:

  • 定义子问题:F(n) = F(n-1) + F(n-2)
  • 反复执行:从 2 循环到 n,执行上述公式
// 时间复杂度 O(n) n是楼梯长度
// 空间复杂度 O(n)

const climbStairs = function(n) {
  if(n < 2) return 1;
  const dp = [1, 1];
  for(let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
};

优化空间复杂度为O(1):

const climbStairs = function(n) {
  if(n < 2) return 1;
  let dp0 = 1;
  let dp1 = 1;
  for(let i = 2; i <= n; i++) {
    const temp = dp0;
    dp0 = dp1;
    dp1 = dp0 + temp;
  }
  return dp1;
};

LeetCode: 198.打家劫舍

解题思路:

  • f(k)=从前 k 个房屋中能偷窃到的最大数额
  • Ak =第 k 个房屋的钱数
  • f(k) = max(f(k - 2) + Ak, f(k - 1))
  • 考虑使用动态规划

解题步骤:

  • 定义子问题:f(k) = max(f(k - 2) + Ak, f(k - 1))
  • 反复执行:从 2 循环到 n ,执行上述公式
// 时间复杂度 O(n) n是楼梯长度
// 空间复杂度 O(n)

const rob = function(nums) {
  if(nums.length === 0) return 0;

  const dp = [0, nums[0]];

  for(let i = 2; i <= nums.length; i++) {
    dp[i] = Math.max(dp[i - 2] + nums[i - 1], dp[i - 1]);
  }
  return dp[dp.length - 1];
};

优化空间复杂度:

const rob = function(nums) {
  if(nums.length === 0) return 0;

  let dp0 = 0;
  let dp1 = nums[0];

  for(let i = 2; i <= nums.length; i++) {
    const dp2 = Math.max(dp0 + nums[i - 1], dp1);
    dp0 = dp1;
    dp1 = dp2;
  }
  return dp1;
};

贪心算法

  • 贪心算法是算法设计中的一种方法

  • 期盼通过每个阶段的局部最优选择,从而达到全局的最优

  • 所以结果并不一定是最优

LeetCode: 455.分发饼干

解题思路:

  • 局部最优:既能满足孩子,还消耗最少
  • 先将“较小的饼干〞分给“胃口最小”的孩子

解题步骤:

  • 对饼干数组和胃口数组升序排序
  • 遍历饼干数组,找到能满足第一个孩子的饼干
  • 然后继续遍历饼干数组,找到满足第二、三、n个孩子的饼干
// 每个孩子都有一个胃口g. 每个孩子只能拥有一个饼干
// 输入: g = [1,2,3], s = [1,1]
// 输出: 1
// 三个孩子胃口值分别是1,2,3  但是只有两个饼干,所以只能让胃口1的孩子满足


// 时间复杂度 O(n * logn) 
// 空间复杂度 O(1)
const findContentChildren = function(g, s) {
  const sortFun = (a, b) => {
    return a - b;
  }
  g.sort(sortFun);
  s.sort(sortFun);

  let i = 0;
  s.forEach(item => {
    // 如果饼干能满足第一个孩子
    if(item >= g[i]) {
      // 就开始满足第二个孩子
      i += 1;
    }
  })
  return i;
};

LeetCode: 122.买卖股票的最佳时机Ⅱ

解题思路:

  • 前提:上帝视角,知道未来的价格
  • 局部最优:见好就收,见差就不动,不做任何长远打算

解题步骤:

  • 新建一个变量,用来统计总利润
  • 遍历价格数组,如果当前价格比昨天高,就在昨天买,今天卖,否则就不交易
  • 遍历结束后,返回所有利润之和
// 时间复杂度 O(n) n为股票的数量
// 空间复杂度 O(1)

const maxProfit = function(prices) {
  let profit = 0;
  for(let i = 1; i < prices.length; i++) {
    // 不贪 如有更高的利润就直接卖出
    if(prices[i] > prices[i -1]) {
      profit += prices[i] - prices[i - 1];
    }
  }
  return profit;
};

回溯算法

  • 回溯算法是算法设计中的一种方法
  • 回溯算法是一种渐进式寻找并构建问题解决方式的策略
  • 回溯算法会先从一个可能的动作开始解决问题,如果不行,就回溯并选择另一个动作,直到将问题解决

什么问题适合用回溯算法解决?

  • 有很多路
  • 这些路里,有死路,也有出路
  • 通常需要递归来模拟所有的路

LeetCode: 46.全排列

解题思路:

  • 要求:1、所有排列情况;2、没有重复元素
  • 有出路、有死路
  • 考虑使用回溯算法

解题步骤:

  • 用递归模拟出所有情况
  • 遇到包含重复元素的情况,就回溯
  • 收集所有到达递归终点的情况,并返回
// 时间复杂度 O(n!) n! = 1 * 2 * 3 * ··· * (n-1) * n;
// 空间复杂度 O(n)

const permute = function(nums) {
  const res = [];
  const backTrack = (path) => {
    if(path.length === nums.length) {
      res.push(path);
      return;
    }

    nums.forEach(n => {
      if(path.includes(n)) return;
      backTrack(path.concat(n));
    })
  }
  backTrack([]);

  return res;
};

LeetCode:78.子集

解题思路:

  • 要求:1、所有子集;2、没有重复元素
  • 有出路、有死路

解题步骤:

  • 用递归模拟出所有情况
  • 保证接的数字都是后面的数字
  • 收集所有到达递归终点的情况,并返回
// 时间复杂度 O(2 ^ N) 每个元素都有两种可能(存在或不存在)
// 空间复杂度 O(N)

const subsets = function (nums) {
  // 存放结果数组
  const res = [];

  const backTrack = (path, l, start) => {
    // 递归结束条件
    if (path.length === l) {
      res.push(path);
      return;
    }

    // 遍历输入的数组长度 起始位置是start
    for (let i = start; i < nums.length; i++) {
      // 递归调用 需要保证子集的有序, start为 i+1
      backTrack(path.concat(nums[i]), l, i + 1);
    }
  };

  // 遍历输入数组长度
  for (let i = 0; i <= nums.length; i++) {
    // 传入长度 起始索引
    backTrack([], i, 0);
  }

  return res;
};