一、排序
1.排序和搜索概念
- 排序:把某个乱序的数组变成升序或者降序的数组
- 搜索:找出数组中某个元素的下标
2.JS中的排序和搜索
-
JS中的排序:数组的sort方法
var numbers = [4, 2, 5, 1, 3]; numbers.sort(function(a, b) { return a - b; }); console.log(numbers); 也可以写成: var numbers = [4, 2, 5, 1, 3]; numbers.sort((a, b) => a - b); console.log(numbers); // [1, 2, 3, 4, 5]
-
JS中的搜索:数组的indexOf方法
const beasts = ['ant', 'bison', 'camel', 'duck', 'bison']; console.log(beasts.indexOf('bison')); // expected output: 1 // start from index 2 console.log(beasts.indexOf('bison', 2)); // expected output: 4 console.log(beasts.indexOf('giraffe')); // expected output: -1
3.排序算法
- 冒泡排序
- 选择排序
- 插入排序
- 归并排序
- 快速排序
- …
4.搜索算法
- 顺序搜索
- 二分搜索
- …
5.冒泡排序思路
-
比较所有相邻元素,如果第一个比第二个大,则交换它们
-
一轮下来,可以保证最后一个数是最大的
-
执行n-1轮,就可以完成排序
-
时间复杂度:冒泡排序的时间复杂度为O(n^2),因为含有两个嵌套循环。
Array.prototype.bubbleSort = function() { for(let i = 0; i < this.length - 1; i++) { for(let j = 0; j < this.length - 1 - i; j++) { if(arr[j] > arr[j + 1]) { const temp = arr[j + 1]; arr[j + 1] = arr[j]; arr[j] = temp; } } } } const arr = [5,4,3,2,1]; arr.bubbleSort(); console.log(arr);
6.选择排序的思路
-
找到数组中的最小值,选中它并将其放置在第一位
-
接着找到第二小的值,选中它并将其放置在第二位
-
以此类推,执行n-1轮
-
时间复杂度:选择排序的时间复杂度为O(n^2),因为含有两个嵌套循环。
Array.prototype.selectionSort = function() { for(let i = 0; i < this.length; i++) { let minIndex = i; for(let j = i; j < this.length; j++) { if(arr[j] < arr[minIndex]) { minIndex = j; } } //增加一个逻辑,若最小值和i相等则不需要交换了 if(minIndex !== i) { const temp = arr[i]; arr[i] = arr[minIndex]; arr[minIndex] = temp; } } } const arr = [5,4,3,2,1]; arr.selectionSort(); console.log(arr);
7.插入排序的思路
-
从第二个数开始往前比
-
比它大就往后排
-
依次类推进行到最后一个数
-
时间复杂度:插入排序的时间复杂度为O(n^2),因为同样含有两个嵌套循环。
Array.prototype.insertionSort = function() { for(let i = 1; i < this.length; i++) { const temp = this[i]; let j = i; while(j > 0) { if(this[j - 1] > temp) { this[j] = this[j - 1]; }else { break; } j--; } this[j] = temp; } } const arr = [5,4,3,2,1]; arr.insertionSort(); console.log(arr);
8、归并排序的思路(比前三种方法性能都更好)
-
分:把数组劈成两半,再递归地对子数组进行“分”操作,直到分成一个个单独的数。
-
合:把两个数合并为有序数组,再对有序数组进行合并,直到全部子数组合并为一个完整数组。
-
合并两个有序数组
- 新建一个空数组res,用于存放最后排序后的数组
- 比较两个有序数组的头部,较小者出队并推入res中
- 如果两个数组还有值,就重复第二步
-
时间复杂度:归并排序进行“分操作”的时间复杂度是O(logN),一般涉及到二分操作我们其实就可以直接联想到logN。“合操作”的时间复杂度是O(n),因为使用了一个while循环。故归并排序的时间复杂度为:O(n * logN)
Array.prototype.mergeSort = function() { const rec = (arr) => { if(arr.length === 1) {return arr;} const mid = Math.floor(arr.length / 2); const left = arr.slice(0, mid); const right = arr.slice(mid, arr.length); const orderLeft = rec(left); const orderRight = rec(right); const res = []; while(orderLeft.length || orderRight.length) { if(orderLeft.length && orderRight.length) { res.push(orderLeft[0] < orderRight[0] ? orderLeft.shift() : orderRight.shift()); }else if(orderLeft.length) { res.push(orderLeft.shift()); }else if(orderRight.length) { res.push(orderRight.shift()); } } return res; } const res = rec(this); res.forEach((v, i) => { this[i] = v; }) } const arr = [5,4,3,2,1]; arr.mergeSort(); console.log(arr);
9.快速排序思路
-
分区:从数组中任意选择一个“基准”,所有比基准小的元素放在基准前面,比基准大的元素放在基准的后面。
-
递归:递归地对基准前后的子数组进行分区。
-
时间复杂度:首先递归的时间复杂度为O(logN),分区操作的时间复杂度是O(n),所以快速排序的时间复杂度为O(n * logN)。
Array.prototype.quickSort = function() { const rec = (arr) => { if(arr.length === 1) {return arr;} const left = []; const right = []; const mid = arr[0]; for(let i = 1; i < arr.length; i++) { if(arr[i] < mid) { left.push(arr[i]); }else{ right.push(arr[i]); } } return [...rec(left), mid, ...rec(right)]; }; const res = rec(this); res.forEach((v, i) => { this[i] = v; }) } const arr = [2,4,5,3,1]; arr.quickSort(); console.log(arr);
二、搜索
1.顺序搜索的思路
-
遍历数组
-
找到跟目标值相等的元素,就返回它的下标
-
遍历结束后,如果没有搜索到目标值,就返回-1。
-
时间复杂度:因为遍历数组是一个循环操作,所以时间复杂度为O(n)。
Array.prototype.sequentialSearch = function(item) { for(let i = 0; i < this.length; i++) { if(this[i] === item) { return i; } } return -1; } const arr = [5,3,2,1,4]; console.log(arr.sequentialSearch(3));
2.二分搜索的思路(使用二分搜索的前提是该数组是有序的)
-
从数组的中间元素开始,如果中间元素正好是目标值,则搜索结束。
-
如果目标值大于或者小于中间元素,则在大于或者小于中间元素的那一半数组中搜索。
-
时间复杂度:由于每一次比较都使得搜索范围缩小一半,故二分搜索的时间复杂度为O(logN)。
Array.prototype.binarySearch = function(item) { let low = 0; let high = this.length - 1; while(low <= high) { const mid = Math.floor((low + high) / 2); if(this[mid] < item) { low = mid + 1; }else if(this[mid] > item) { high = mid - 1; }else { return mid; } } return -1; } const arr = [1,2,3,4,5]; console.log(arr.binarySearch(0));
3.合并两个有序链表(题号Leetcode21)
-
解题思路:
- 与归并排序中的合并两个有序数组很相似
- 将数组替换成链表就能解此题
-
解题步骤:
- 新建一个新链表,作为返回结果
- 用指针遍历两个有序数组,并比较两个链表的当前节点,较小者先接入新链表,并将指针后移一步
- 链表遍历结束,返回新链表
- 解法2的时间复杂度为O(n),n为两个链表的总长度
解法1(自己写的):
var mergeTwoLists = function(l1, l2) {
let p1 = l1;
let p2 = l2;
let res = new ListNode(0);
let p3 = res;
while(p1 || p2) {
if(p1 && p2) {
if(p1.val < p2.val) {
p3.next = new ListNode(p1.val);
p1 = p1.next;
p3 = p3.next;
}else {
p3.next = new ListNode(p2.val);
p2 = p2.next;
p3 = p3.next;
}
}else if(p1) {
p3.next = new ListNode(p1.val)
p1 = p1.next;
p3 = p3.next;
}else if(p2) {
p3.next = new ListNode(p2.val)
p2 = p2.next;
p3 = p3.next;
}
}
return res.next;
};
解法2:(老师写的)
var mergeTwoLists = function(l1, l2) {
let p1 = l1;
let p2 = l2;
let res = new ListNode(0);
let p3 = res;
while(p1 && p2) {
if(p1.val < p2.val) {
p3.next = new ListNode(p1.val);
p1 = p1.next;
}else {
p3.next = new ListNode(p2.val);
p2 = p2.next;
}
p3 = p3.next;
}
if(p1) {
p3.next = p1;
}
if(p2) {
p3.next = p2;
}
return res.next;
};
4.猜数字大小(题号Leetcode374)
-
解题思路:
- 这就是二分搜索
- 区别就是,该题需要调用guess函数,来判断中间元素是否是目标值
-
解题步骤:
- 从数组的中间元素开始,如果中间元素正好是目标值,则搜索过程结束。
-
如果目标值大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找。
- 该解法的时间复杂度为O(logN),空间复杂度为O(1)。
var guessNumber = function(n) { let low = 1; let high = n; while(low <= high) { const mid = Math.floor((low + high) / 2); const res = guess(mid); if(res === -1) { high = mid - 1; }else if(res === 1) { low = mid + 1; }else if(res === 0) { return mid; } } };
三、分而治之
1.分而治之概念
- 分而治之是算法设计中的一种方法。
- 它将一个问题分成多个和原问题相似的小问题,递归解决小问题,再将结果合并以解决原来的问题。
2.使用场景
- 场景一:归并排序
- 分:把数组从中间一分为二。
- 解:递归地对两个子数组进行归并排序。
- 合:合并有序子数组。
- 场景二:快速排序
- 分:选基准,按基准把数组分成两个子数组。
- 解:递归地对两个子数组进行快速排序。
- 合:对两个子数组进行合并。
3.猜数字大小(题号Leetcode374)
利用分而治之方法解决该问题。
这种方案的时间复杂度还是O(logN),因为还是采用一分为二的做法。但空间复杂度是指数增长的,关键看递归了多少层,该题的空间复杂度还是O(logN)。
var 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(low, mid - 1);
}
}
return rec(1, n);
};
4.翻转二叉树(题号Leetcode226)
-
解题思路
- 先翻转左右子树,再将子树换个位置
- 符合“分、解、合 ”特性
- 考虑选择分而治之
-
解题步骤
- 分:获取左右子树
- 解:递归地翻转左右子树
- 合:将翻转后的左右子树换个位置放到根节点上
-
复杂度
- 时间复杂度:该解法的时间复杂度其实就看它执行了几次,我们可知其执行了n次,n为二叉树节点数量。所以,时间复杂度为O(n)。
- 空间复杂度:因为是一个递归方法,这是一个堆栈。空间复杂度为O(h),h为树的高度。
var invertTree = function(root) { if(!root) {return null;} return { val: root.val, left: invertTree(root.right), right: invertTree(root.left), } };
5.相同的树(题号Leetcode100)
-
解题思路:
- 两个树:根节点的值相同,左子树相同,右子树相同。
- 符合“分、解、合”特性。
- 考虑选择分而治之。
-
解题步骤:
-
分:获取两个树的左子树和右子树
-
解:递归地判断两个树的左子树是否相同,右子树是否相同
-
合:将上述结果合并,如果根节点的值也相同,树就相同
-
-
算法的复杂度:
- 时间复杂度:因为遍历了所有节点,所以时间复杂度是O(n),n为节点数量。
- 空间复杂度:由于是递归,在内部形成了一个堆栈,在最坏的情况下空间复杂度为O(n),在最好的情况下是O(logN),取决于节点的分布。
var 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; };
代码写法2:
var isSameTree = function(p, q) { if(p == null && q == null) { return true } if(p == null || q == null) { return false } if(p.val != q.val) { return false } return isSameTree(p.left, q.left) && isSameTree(p.right, q.right) }
6.对称二叉树(题号Leetcode101)
-
解题思路:
- 转化为:左右子树是否镜像。
- 分解为:树1的左子树和树2的右子树是否镜像,树1的右子树和树2的左子树是否镜像。
- 符合“分、解、合”特性,考虑选择分而治之
-
解题步骤:
- 分:获取两个树的左子树和右子树。
- 解:递归地判断树1的左子树和树2的右子树是否镜像,树1的右子树和树2的左子树是否镜像。
- 合:如果上述都成立,且根节点值也相同,两个树就镜像。
-
算法的复杂度:
- 时间复杂度:因为该算法访问了所有的节点,所以时间复杂度是O(n),n为二叉树的节点数。
- 空间复杂度:空间复杂度为O(h),h为二叉树的高度,最坏的情况下h=n.
var 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) };
代码写法2:
var isSymmetric = function(root) { if(!root) {return true} const isMirror = (l, r) => { if(l == null && r == null) { return true } if(l == null || r == null) { return false } if(l.val != r.val) { return false } return isMirror(l.left, r.right) && isMirror(l.right, r.left) } return isMirror(root.left, root.right) }
四、动态规划
1.动态规划概念
- 动态规划是算法设计中的一种方法。
- 它将一个问题分解为相互重叠的子问题,通过反复求解子问题,来解决原来的问题。
2.动态规划使用场景
- 斐波那契数列:n=0时,F(n)=0;n=1时,F(n)=1;n>=2时,F(n) = F(n - 1) + F(n - 2)。
- 定义子问题:F(n) = F(n - 1) + F(n - 2)。
- 反复执行:从2循环到n,执行上述公式。
3. 动态规划和分而治之的区别
- 这两种方式的最大区别就在于它们的子问题是否是独立的?
- 如果它们的子问题是相互重叠的则是动态规划;
- 如果它们的子问题是独立的则是分而治之。
4.爬楼梯(题号Leetcode70)
-
解题思路:
- 爬到第 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)。
- 空间复杂度:O(n)。
-
方法1:使用数组存储爬上每一阶的方法
var 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];
};
-
方法2:其实可以直接用两个变量存储数据降低空间复杂度,这时空间复杂度为O(1)。
var 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 = dp1 + temp; } return dp1; };
5.打家劫舍(题号Leetcode198)
-
解题思路:
- 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,执行上述公式。
-
算法复杂度:
- 时间复杂度:因为有一个for循环,所以时间复杂度为O(n),n为nums的长度。
- 空间复杂度:dp为一个数组,所以空间复杂度为O(n)。
- 方法一:
var rob = function(nums) { if(!nums) {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[nums.length]; };
- 方法二:从数组改用变量,降低空间复杂度。
var rob = function(nums) { if(!nums) {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; };
6.动态规划的步骤
- 定义子问题
- 反复执行
五、贪心算法
1.贪心算法概念
- 贪心算法是算法设计中的一种方法。
- 期盼通过每个阶段的局部最优选择,从而达到全局的最优。
- 结果不一定是最优。
2.分饼干(题号Leetcode455)
-
解题思路:
- 本题的局部最优:既能满足孩子,还消耗最少。
- 可以先将“较小的饼干”分给“胃口最小”的孩子。
-
解题步骤:
- 对饼干数组和胃口数组升序排序。
- 遍历饼干数组,找到能满足第一个孩子的饼干。
- 然后继续遍历饼干数组,找到满足第二、三、…、n个孩子的饼干。
-
算法复杂度:
- 时间复杂度:因为快速分类的时间复杂度为 O(nlogN ),for循环的时间复杂度为O(n), 两者之间取较大者,故为O(nlogN)。
- 空间复杂度:因为两个数组g和s都是之前就存在的,并未临时创建。所以,该算法的空间复杂度为O(1)。
var findContentChildren = function(g, s) { const sortFunc = (a, b) => { return a - b; } g.sort(sortFunc); s.sort(sortFunc); let i = 0; s.forEach( n => { if(n >= g[i]) { i++; } }) return i; };
3.买卖股票的最佳时机II(题号Leetcode122)
-
解题思路:
- 前提:上帝视角,知道未来价格。
- 局部最优:见好就收,见差就不动,不做任何长远打算。
-
解题步骤:
- 新建一个变量,用来统计总利润。
- 遍历价格数组,如果当前价格比昨天高,就在昨天买,今天卖,否则就不交易。
- 遍历结束后,返回所有利润之和。
-
算法的复杂度:
- 时间复杂度:O(n)。
- 空间复杂度:O(1)。
var 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; };
六、回溯算法
1.回溯算法的概念
- 回溯算法是算法设计中的一种方法。
- 回溯算法是一种渐进式寻找并构建问题解决方式的策略。
- 回溯算法会先从一个可能的动作开始解决问题,如果不行,就回溯并选择另一个动作,直到将问题解决。
2.什么问题适合用回溯算法解决?
- 有很多路。(这里的路是一个比喻,有的时候表示路径,有时候表示排列情况等等。)
- 这些路里,有死路,也有出路。(死路表示不符合要求的情况,出路即符合题目要求的情况)
- 通常需要递归来模拟所有路。
3.全排列(题号Leetcode46)
-
解题思路:
- 题目要求:1、所有排列情况;2、没有重复元素
- 有出路、有死路
- 考虑使用回溯算法
-
解题步骤:
- 用递归模拟出所有情况
- 遇到包含重复元素的情况,就回溯
- 收集所有到达递归终点的情况,并返回
-
算法的复杂度:
- 时间复杂度:因为每一次递归里面都有一个for循环,但又因为重复序列的数字不会输出,所以每次递归的循环次数都会减少,故时间复杂度为O(n!),,n!=1x2x3x…x (n-1) x n
- 空间复杂度:O(n),n为数组的长度。
var permute = function(nums) { const res = []; const backTrack = (path) => { if(path.length === nums.length) { res.push(path); return; } nums.forEach((k) => { if(path.includes(k)) {return ;} backTrack(path.concat(k)); }) } backTrack([]); return res; };
4.子集(题号Leetcode78)
-
解题思路:
- 题目要求:1、所有子集(注意,子集是有顺序性的);2、没有重复元素。
- 有出路、有死路。
- 考虑使用回溯算法。
-
解题步骤:
- 用递归模拟出所有情况。
- 保证接的数字都是后面的数字。
- 收集所有到达递归终点的情况,并返回。
-
算法的复杂度:
- 时间复杂度:O(2^n),因为每个元素都有两种可能。n为nums的长度。
- 空间复杂度:O(n)
var subsets = function(nums) { const res = []; const backTrack = (path, l, start) => { if(path.length === l) { res.push(path); return ; } for(let i = start; i < nums.length; i++) { backTrack(path.concat(nums[i]), l, i+1); //这里传入i+1的目的是保证子集顺序性 } } //表示需要的子集长度 for(let i = 0; i <= nums.length; i++) { backTrack([], i, 0) } return res; };
七、总结
1.重点难点
- 数据结构:所有数据结构都很重要,跟前端最相关的是链表和树。
- 算法:链表/树/图的遍历、数组的排序和搜索…
- 设计思想:分而治之、动态规划较常考,贪心、回溯次之。
2.经验心得
- 搞清楚数据结构与算法的特点和应用场景。
- 用JS实现一遍,最好能用第二第三语言再实现一遍。
- 学会分析时间/空间复杂度。
- 提炼前端和算法的结合点,用于工作实践。
3.拓展建议
- 多刷题,最好保证300道以上。
- 多总结各种套路、模板。
- 多阅读源码,比如React、Lodash、V8…
- 多实战,将数据结构与算法用于工作。