Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情。
1. 时间复杂度和空间复杂度
算法是指用来操作数据,解决程序问题的一组方法。通过时间复杂度和空间复杂度可以衡量算法的优劣。通常时间复杂度比空间复杂度更容易出问题,所以更多研究的是时间复杂度。
1.1 时间复杂度
通常来说,运行一遍程序就可以得到对应的消耗时间,可是由于机器性能,数据规模等因素不同都有可能得出不同的结果。理论上来说,只要得到一个时间评估的指标,获得算法消耗时间的大致趋势即可。
通常,一个算法所花费的时间和代码语句执行次数成正比,代码执行次数越多,消耗的时间越长。我们把一个算法的执行次数称为时间频度,记作T(n)。渐进时间复杂度(简称时间复杂度)用大写O表示,所以也称作大O表示法,算法的时间复杂度为:T(n)=O(f(n))。它表示当 n 趋于正无穷大时,T(n) 的趋近于 f(n)。常见的时间复杂度有:O(1)常数型;O(log n)对数型;O(n)线性型;O(nlog n)线性对数型;O(n2)平方型;O(2n)指数型。
上图为不同类型函数的增长趋势图,可以看出,随着问题规模 n 的不断扩大,时间复杂度也跟着不断增大。常见的算法时间复杂度由小到大依次为:Ο(1)<Ο(log n)<Ο(n)<Ο(nlog n)<Ο(n2)<<Ο(2^n)。值的注意的是,算法时间复杂度的大小比较只是从增长趋势来看,在某个阈值之前可能会出现相反的结果。
时间复杂度一般是这样计算的,找到执行语句,计算语句的执行次数,用大O进行表示。其中在用大O表示的时候,使用1表示常量级的执行次数,只保留函数的最高阶项,存在最高阶去掉前面的系数。
int j = 0; // ①
for (int i = 0; i < n; i++) { // ②
j = i; // ③
j++; // ④
}
复制代码
上述代码中,语句①的频度是1,语句②的频度是 n,语句③的频度是 n-1,语句④的频度是 n-1,所以时间频度 T(n)=1+n+n-1+n-1=3n-1,省略系数和常数项得到 T(n)=O(n)
1.2 空间复杂度
空间复杂度主要指执行算法所需的内存的大小,类似于时间复杂度,空间复杂度也是预估的,使用 S(n)=O(f(n)) 表示,S(n) 是空间复杂度,所需的存储空间使用 f(n) 表示。
int j = 0;
int[] m = new int[n];
for (int i = 1; i <= n; ++i) {
j = i;
j++;
}
复制代码
上述代码中,只有创建 m 的时候分配了数组空间,在 for 循环当中没有增加内存空间,所以空间复杂度 S(n)=O(n)。
2. 题目
2.1 两数之和
题目是需要返回两数之和等于目标数的数组下标,不同于返回两数之和等于目标数的两个数,这里不能修改数组下标的位置,因而不能使用双指针法。在编写具体的代码过程中,使用了 map,然后对数组中的数据进行遍历,map 中没有的数则保存和为目标数的另一个数做为键,而该数的下标作为值;map 中有该数则获取对应的值和该数的下标放到数组中并返回。
var twoSum = function (nums, target) {
let map = new Map()
for (let i = 0; i < nums.length; i++) {
if (!map.has(nums[i])) {
map.set(target - nums[i], i)
} else {
return [map.get(nums[i]), i]
}
}
return []
};
复制代码
2.2 三数之和
求三数之和的时候用到了双指针法,所以首先需要按照递增的顺序进行排列。第一个数通过数组遍历进行获取,第二和第三个数通过双指针法进行获取,并把每次满足条件的三个数放到数组里面。值得注意的是,题意中明确说明不能包含重复的三元组,所以每确定一个数都需要进行去重,防止重复。而且获取到符合题意的三元组后,需要先去重然后再移动下标。
var threeSum = function (nums) {
if(nums.length<3) return []
nums.sort((a, b) => a - b)
let res = []
for (let i = 0; i < nums.length - 1; i++) {
if (i > 0 && nums[i] === nums[i - 1]) continue // 去重
let l = i + 1, r = nums.length - 1
while (l < r) {
let sum = nums[i] + nums[l] + nums[r]
if (sum === 0) {
res.push([nums[i], nums[l], nums[r]])
while (l < r && nums[l] === nums[l + 1]) { // 去重
l++
}
while (l < r && nums[r] === nums[r - 1]) { // 去重
r--
}
l++
r--
} else if (sum < 0) {
l++
} else {
r--
}
}
}
return res
};
复制代码
2.3 四数之和
看过三数之和后,四数之和很好理解,前两个数通过数组遍历获取,后两个通过双指针获取。
var fourSum = function (nums, target) {
if (nums.length < 4) return []
nums.sort((a, b) => a - b)
let res = []
for (let i = 0; i < nums.length - 2; i++) {
if (i > 0 && nums[i] === nums[i - 1]) continue
for (let j = i + 1; j < nums.length - 1; j++) {
if (j > i + 1 && nums[j] === nums[j - 1]) continue
let l = j + 1, r = nums.length - 1
while (l < r) {
let sum = nums[i] + nums[j] + nums[l] + nums[r]
if (sum === target) {
res.push([nums[i], nums[j], nums[l], nums[r]])
while (l < r && nums[l] === nums[l + 1]) {
l++
}
while (l < r && nums[r] === nums[r - 1]) {
r--
}
l++
r--
} else if (sum < target) {
l++
} else {
r--
}
}
}
}
return res
}
复制代码
2.4 最接近的三数之和
类似于三数之和,这里另外需要做的是,每次得到三数之和后都需要和之前的进行比较,找到最接近的。
var threeSumClosest = function (nums, target) {
nums.sort((a,b)=>a-b)
let res=nums[0]+nums[1]+nums[nums.length-1]
for(let i=0;i<nums.length-1;i++){
let l=i+1,r=nums.length-1
while(l<r){
let sum=nums[i]+nums[l]+nums[r]
if(sum<target){
l++
}else{
r--
}
if(Math.abs(target-res)>Math.abs(target-sum)){
res=sum
}
}
}
return res
};
复制代码
2.5 二叉树的前序遍历
首先要知道,前序遍历先获取根节点,再获取左子树节点,最后获取右子树节点。当然递归的方式比较简单,感兴趣的话可研究迭代的写法。
var preorderTraversal = function (root) {
let res = []
function traversal(tree) {
if (!tree) return
res.push(tree.val)
traversal(tree.left)
traversal(tree.right)
}
traversal(root)
return res
};
复制代码
2.6 二叉树的中序遍历
中序遍历先获取左子树的节点,再获取根节点,最后获取右子树的节点。
var inorderTraversal = function(root) {
let result=[]
function inorder(tree){
if(!tree) return
inorder(tree.left)
result.push(tree.val)
inorder(tree.right)
}
inorder(root)
return result
};
复制代码
2.7 二叉树的后序遍历
前序遍历先获取左子树的节点,再获取右子树节点,最后获取根节点。
var postorderTraversal = function(root) {
let result=[]
function postorder(tree){
if(!tree) return
postorder(tree.left)
postorder(tree.right)
result.push(tree.val)
}
postorder(root)
return result
};
复制代码
2.8 二叉树的最大深度
使用了 DFS,如果节点为空返回0,否则获取左子树和右子树的深度并计算出较大的值,由于当前节点不为空所以需要增加1。
var maxDepth = function (root) {
if (!root) return 0
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1
};
复制代码
2.9 平衡二叉树
这题和求二叉树的最大深度关联。如果节点为空直接判断为平衡二叉树,否则判断左右子树的高度差是否大于1,大于的话不是平衡二叉树,否则继续递归判断左子树和右子树。
var isBalanced = function (root) {
if (!root) return true
function getHeight(tree) {
if (!tree) return 0
return Math.max(getHeight(tree.left), getHeight(tree.right)) + 1
}
if (Math.abs(getHeight(root.left) - getHeight(root.right)) > 1) {
return false
}
return isBalanced(root.left) && isBalanced(root.right)
};
复制代码
2.10 二叉树的镜像
如果节点为空,返回 null,否则获取左右子树的镜像,交换子树的位置。
var mirrorTree = function (root) {
if (!root) return null;
let left = mirrorTree(root.right);
let right = mirrorTree(root.left);
root.left = left;
root.right = right;
return root;
}
复制代码
2.11 对称的二叉树
leetcode 地址 这里通过 compare 函数比较两个相同的树,如果都为空则是对称的二叉树,一个为空另一个不为空则不是对称的二叉树,其他情况需要比较两个节点的值,如果相等需要递归比较第一颗树的左节点和第二颗树的右节点,如果也相等则需要递归比较第一颗树的右节点和第二颗树的左节点。
var isSymmetric = function (root) {
if (!root) return true
function compare(left, right) {
if (!left && !right) return true
else if (!left || !right) return false
return left.val === right.val && compare(left.left, right.right) && compare(left.right, right.left)
}
return compare(root, root)
};
复制代码
2.12 合并两个有序链表
leetcode 地址 如果一个链表为空,则返回另一个链表。如果其中一个链表的结点值比另一个链表的结点值要大,则将值较大的链表和该链表同另一个链表 merge 后的结果进行合并,返回该链表。
var mergeTwoLists = function (list1, list2) {
if (!list1) return list2
if (!list2) return list1
if (list1.val < list2.val) {
list1.next = mergeTwoLists(list1.next, list2)
return list1
} else {
list2.next = mergeTwoLists(list1, list2.next)
return list2
}
};
复制代码
2.13 相交链表
本题使用了双指针的思想。如果两个链表有一个为空,则返回 null。在两个链表都不为空的情况,将指针 a,b 分别指向 headA 和 headB,如果 a,b 两个指针不相等则一直进行判断,指针 a 不为空指向下一个节点,为空则指向 headB,同理,指针 b 不为空指向下一个节点,为空志向 headA;如果 a,b 两个指针相等(可能都为 null)则返回其中一个指针,这个就是相交节点(可能为 null)
var getIntersectionNode = function (headA, headB) {
if (headA === null || headB === null) {
return null
}
let a = headA, b = headB
while (a !== b) {
a = a !== null ? a.next : headB
b = b !== null ? b.next : headA
}
return a
};
复制代码
2.14 删除链表中的节点
这里要删除 node 节点,就直接把 node 节点值修改为 node.next 节点值即可。
var deleteNode = function (node) {
node.val = node.next.val
node.next = node.next.next
};
复制代码
2.15 环形链表
本题使用了快慢指针的思想。使用快指针进行迭代,如果快指针的下一个节点为 null,则返回 null,否则快指针走两步,慢指针走一步。当快慢指针相遇后就说明有环,于是将快指针重置到头节点,然后快慢指针再没次走一步,当它们再次相遇后就是环的入口。
var detectCycle = function (head) {
let fast = head, slow = head
while (fast) {
if (fast.next === null) return null
fast = fast.next.next
slow = slow.next
if (fast === slow) {
fast = head
while (true) {
if (fast === slow) return slow
fast = fast.next
slow = slow.next
}
}
}
return null
};
复制代码
2.16 从尾到头打印链表
使用一个数组存放打印结果,遍历链表的每一个节点,把结果从数组的头部进行插入,得到的数组则是从尾到头的打印结果。
var reversePrint = function (head) {
let res = []
while (head) {
res.unshift(head.val)
head = head.next
}
return res
};
复制代码
2.17 链表中倒数第K个节点
本题是用了快慢指针的思想。先让快指针走 k 步,走完 k 步之后,快慢指针一起走,返回慢指针。
var getKthFromEnd = function (head, k) {
let fast = head,slow = head
while (fast) {
fast = fast.next
k--
if (k < 0) {
slow = slow.next
}
}
return slow
}
复制代码
2.18 反转链表
将 prev 做为 head 的前一个节点,将 prev 指向 head.next 节点,将 head.next 和 head 分别指向对应的前一个节点
var reverseList = function (head) {
let prev = null
while (head) {
[head.next, prev, head] = [prev, head, head.next]
}
return prev
};
复制代码
2.19 爬楼梯
本题可使用动态规划的思想。首先,可以把 f(x) 当作爬 x 阶楼梯的方案数,那么可以得到方程 f(x)=f(x-1)+f(x-2),也就是说 x 阶楼梯的方案数就是 x-1 阶楼梯的方案数加上 x-2 阶楼梯的方案数。如果使用数组来存放每一阶楼梯的方案数可以得出空间复杂度为 O(n) 的方案,可实际上只需要得出 x 阶楼梯的方案数,于是可以利用滚动数组思想,把空间复杂度降为 O(1)
var climbStairs = function (n) {
let prev = 1, cur = 1
for (let i = 2; i <= n; i++) {
const temp = cur
cur += prev
prev = temp
}
return cur
};
复制代码
2.20 斐波那契数列
和爬楼梯类似,应该说爬楼梯就是斐波那契数列的应用。
var fib = function (n) {
if (n < 2) return n
let prev = 0, cur = 1
for (let i = 2; i <= n; i++) {
const temp = cur
cur = (cur + prev) % 1000000007
prev = temp
}
return cur
};
复制代码
2.21 连续子数组的最大和
本题使用动态规划的思想。遍历数组,把每个数和增加该数后的累加值进行比较,取较大的一个值,再把得到的值和总的累加和进行比较取较大值。
var maxSubArray = function (nums) {
let prev = 0, sum = nums[0]
for (const num of nums) {
prev = Math.max(prev + num, num)
sum = Math.max(prev, sum)
}
return sum
}
复制代码
2.22 和为s的连续正数序列
本题采用滑动窗口的思想。首先找到中间值 mid,当 i 小于或等于 mid 的时候执行循环。当累加值小于目标值的时候累加值递增,数值存放到临时数组 temp;当累加值大于或等于目标值的时候,如果累加值等于目标值,临时数组 temp 可以放到结果数组中,而累计值通过减少 temp 数组中第一个元素值的方式进行递减。递减到小于目标值的时候又再次递增。
var findContinuousSequence = function (target) {
let mid = Math.ceil(target / 2)
let i = 1, sum = 0, res = [], temp = []
while (i <= mid) {
while (sum < target) {
sum += i
temp.push(i++)
}
while (sum >= target) {
if (sum === target) {
res.push([...temp])
}
sum -= temp.shift()
}
}
return res
}
复制代码
2.23 无重复字符的最长子串
本题采用滑动窗口的思想。遍历数组的每个元素,存放到一个数组中,遇到已经存在于数组的元素,则删除该重复元素之前的数和当前元素。每次遍历的时候都将数组的长度和之前数组的长度进行比较,取较大值。
var lengthOfLongestSubstring = function (s) {
let arr = [], max = 0
for (const char of s) {
const index = arr.indexOf(char)
if (index !== -1) {
arr.splice(0, index + 1)
}
arr.push(char)
max = Math.max(arr.length, max)
}
return max
}
复制代码
2.24 排序数组中只出现一次的数字
看到采用异或的思路解决很快,所以直接用上了。
var singleNonDuplicate = function (nums) {
return nums.reduce((acc, cur) => acc ^ cur)
}
复制代码
2.25 二叉搜索树的第k大节点
利用二叉搜索树的特点,左子树的节点的值比根节点的值小,右子树的节点值比根节点的值大,那么通过反中序遍历即可得到从大到小的数组序列。又因为题意只需要得出第K大节点,所以得到该节点的值后就可以停止递归运算。
var kthLargest = function (root, k) {
let res = root.val
function traversal(tree) {
if (!tree) return
traversal(tree.right)
k--
if (k === 0) {
res = tree.val
return
}
traversal(tree.left)
}
traversal(root)
return res
}
复制代码