分而治之
-
分而治之是算法设计中的一种方法
-
它将一个问题分成多个和原问题相似的小问题,递归解决小问题,再将结果合并以解决原来的问题
场景一:归并排序
- 分:把数组从中间一分为二
- 解:递归地对两个子数组进行归并排序
- 合:合并有序子数组
场景二:快速排序
- 分:选基准,按基准把数组分成两个子数组
- 解:递归地对两个子数组进行快速排序
- 合:对两个子数组进行合并
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;
};