❤️ 前言
本文通过文字、图片,讲解必备的基础算法知识,通过力扣
原题实战讲解加深理解。掌握本文所述算法,能应付大部分面试所遇到的面试题。你能收获到常用的基础算法
、数据结构
等。快来跟作者一起学习,点亮自己的技能树,提升技术广度,为技术赋能。
在开始之前先关注一下时间复杂度曲线图🚀
🏹 一、双指针法 Two Pointers
双指针法是经常用到的方法之一,用两个指针解决一个问题。常见的有普通双指针法,对撞指针法,快慢指针法。适用到的题目数组中是否存在两个数的和等于目标值、选择法排序、链表是否存在环、反转链表等。
💘 1.普通双指针法
两个指针的初始位置相近,移动方向相同。通常一个指针用于外层遍历,另一个用于内层遍历。
💞 1.1 两个数的和等于目标值
给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target
的那 两个 整数,并返回它们的数组下标。
示例 1:
输入: nums = [2,7,11,15], target = 9
输出: [0,1]
解释: 因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
示例 2:
输入: nums = [3,2,4], target = 6
输出: [1,2]
示例 3:
输入: nums = [3,3], target = 6
输出: [0,1]
思路如图所示:
代码如下:
var twoSum = function(nums, target) {
for(let i = 0; i < nums.length - 1; i++)
for (let j = i+1; j < nums.length; j++) {
if (nums[i] + nums[j] ===target) {
return [i,j]
}
}
};
时间复杂度: 因为对数组遍历了两次,所以为O(n²)
空间复杂度:O(1) 没有使用额外的变量。
时间复杂度过高,童鞋们想想怎么可以优化呢?
哈哈,是不是有些童鞋想到了解法。其实只要遍历一次即可,每次取一个元素时,只要判断target-当前元素是否存在于输入数组即可,为了把查找target-nums[i]的时间复杂度从O(n)降到O(1)引入一个hasMap即可,因为我们知道hasMap的crud的时间复杂度都是O(1);
优化过后的时间复杂度为O(n),空间复杂度为O(n),主要是hasMap的开销。
大家想想为啥此题不能用对撞指针实现?为啥输入数组有序就可以使用了?
相信聪明的同学已经知道问题所在了。有的同学肯定想到了,用一个map记录原始的数组元素的index,然后对输入数组进行排序,然后返回满足条件的元素的map里面的原始下标即可。如果不存在重复元素,那么确实可以解决。那么对于[3,2,3],target=6这种情况有没有考虑过呢?
问题的原因就是结果需要返回
下标
而输入数组不是有序
且可能存在重复元素
。
➡️2.对撞指针法
存在两个指针一个在头部,一个在尾部,他们的移动方向是面对面的,即一个前移、一个后移。
🎯 2.1 寻找目标值。
给定一个有序的输入数组,寻找数组中两个数的和等于目标值,返回他们的下标。
核心思路
由题可知,输入数组有序。首先申明两个指针,头指针和尾指针。然后头指针和尾指针相加和目标值对比。如果和大于目标值,则需要尾指针前移;如果小于目标值,那么需要头指针后移;等于的话,返回下标即可。当首指针大于尾指针时说明不存在这样的两个元素,返回false即可。
如图所示
代码如下所示
var twoSum = function(nums, target) {
let i = 0 ,j = target.length = 1;
while (i < j) {
const sum = nums[i] + nums[j];
if (sum > target) j--;
else if (sum < target) i++
else return [i, j]
}
return [];
};
时间复杂度:只对输入数据进行了一次遍历,因此是O(n)
空间复杂度:没有使用额外变量,因此是O(1)
🔜3.快慢指针法
存在两个指针,一个快指针,一个慢指针。慢指针通常每次移动一步,快指针通常每次移动两步。
实战题目:
🪐 3.1 判断环形链表
(leetcode143🚀)给你一个链表的头节点 head
,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next
指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos
来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos
不作为参数进行传递 。仅仅是为了标识链表的实际情况。
如果链表中存在环 ,则返回 true
。 否则,返回 false
。
示例 1:
输入: head = [3,2,0,-4], pos = 1
输出: true
解释: 链表中有一个环,其尾部连接到第二个节点
示例 2:
输入: head = [1,2], pos = 0
输出: true
解释: 链表中有一个环,其尾部连接到第一个节点。
示例 3:
输入: head = [1], pos = -1
输出: false
解释: 链表中没有环。
如果存在环,那么快慢指针在移动时,会在某时指向同一个节点。没有环,那么快指针是指向结束节点或者null;
解题思路如下图所示: 代码如下所示:
/**
* @param {ListNode} head
* @return {boolean}
*/
// 快慢指针解法
var hasCycle = function(head) {
if(!head) return false;
let slow = head;
let fast = head.next ? head.next.next : null;
if (!fast) return false;
while(slow && fast && slow !== fast) {
slow = slow.next;
fast = fast.next ? fast.next.next: null;
}
return slow === fast;
};
时间复杂度: O(n), 最坏的情况下就是慢指针走到最后一个节点,快指针已经走了两圈。所以是O(2n) = O(n) 空间复杂度: 只是用了left和slow两个额外变量,不随n的个数变化。所以是O(2) = O(1)
🛥️ 3.2 救生艇的数量
(leetcode881 🚀)给定数组 people
。people[i]
表示第 i
个人的体重 ,船的数量不限,每艘船可以承载的最大重量为 limit
。每艘船最多可同时载两人,但条件是这些人的重量之和最多为 limit
。
返回 承载所有人所需的最小船数 。
示例 1:
输入: people = [1,2], limit = 3
输出: 1
解释: 1 艘船载 (1, 2)
示例 2:
输入: people = [3,2,2,1], limit = 3
输出: 3
解释: 3 艘船分别载 (1, 2), (2) 和 (3)
示例 3:
输入: people = [3,5,3,4], limit = 5
输出: 4
解释: 4 艘船分别载 (3), (3), (4), (5)
提示:
1 <= people.length <= 5 * 104
1 <= people[i] <= limit <= 3 * 104
解题思路:
首先对输入数组进行
排序
,然后定义两个指针分别指向首尾
。首尾指针进行相加如果小于等于limit的话,首指针后移;尾指针指向的人无论如何都是可以做一艘船的,所以每次尾指针都需要前移,船的数量+1
如图所示
代码如下:
var numRescueBoats = function(people, limit) {
// CornerCase
if(!people || people.length === 0) return 0;
people.sort((a, b) => a - b); // 原地从小到大排序
let s = 0, e = people.length - 1;
let result = 0;
while(s <= e) {
if (people[s] + people[e] <= limit) {
s++;
}
// 尾指针始终可以坐一条船的,可以忽略首指针
e--;
result++;
}
return result;
};
🔎 二、二分查找法(Binary Search)
二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储[1]而且表中元素按关键字[2]有序排列。通常时间复杂度为O(logN)
。如果一个顺序的存储结构,要你解决问题时,那么你就要考虑一下二分查找法了。
leetcode实战题目:
💕 2.1 二分查找
(简单)(leetcode 704 🚀)给定一个 n
个元素有序的(升序)整型数组 nums
和一个目标值 target
,写一个函数搜索 nums
中的 target
,如果目标值存在返回下标,否则返回 -1
。
示例 1:
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
示例 2:
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1
提示:
- 你可以假设
nums
中的所有元素是不重复的。 n
将在[1, 10000]
之间。nums
的每个元素都将在[-9999, 9999]
之间。
核心思想:
left=0;right指向最后一个元素。mid=中间值
如果mid < target; 那么搜索区间[mid+1, right]
如果mid > target; 那么搜索区间[left, mid-1] 如果mid = target返回true,如果循环结束left > right 那么返回false
代码如下所示:
function binarySearch(nums, target) {
if (nums === null || nums.length === 0) return -1;
let start = 0;
let end = nums.length - 1;
while(start <= end) {
let mid = Math.floor(start + (end - start) / 2); // 防止溢出
if(nums[mid] === target) {
return mid;
} else if (nums[mid] > target) {
end = mid - 1;
} else {
start = mid + 1;
}
}
return - 1;
}
⛩️ 2.2 搜索插入位置
(中等) (leetcode 35🚀)给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。请必须使用时间复杂度为 O(log n)
的算法。
示例 1:
输入: nums = [1,3,5,6], target = 5
输出: 2
示例 2:
输入: nums = [1,3,5,6], target = 2
输出: 1
示例 3:
输入: nums = [1,3,5,6], target = 7
输出: 4
提示:
1 <= nums.length <= 104
-104 <= nums[i] <= 104
nums
为 无重复元素 的 升序 排列数组-104 <= target <= 104
核心思想:
见代码注释
代码如下所示
/**
* 使用二分法进行查找。无非4种情况。
* 1.在数组的最左边,比数组中的任何元素都要小
* 2.在数组中的最右边,比数组种的任何元素都要大
* 3.在数组中间。
* 4.元素刚好存在于数组中。
* 核心原理:使用left指向最左边,right指向最右边。m标志中间指针。
* 1.nums[m]和target比较, 如果target大于, left = m +1 , 在 [m+1, right]中继续查找;否则执行2
* 如果target小于,right = m - 1; 在[left, m-1]中进行查找
*
* 循环结束的条件
* 1.left=right,在判断target的<和>、=。
* 2.left>right; 说明target刚好在left和right的值中间[7,9],如8;那么返回right+1;也就是9的位置即可。
*
* 时间复杂度: O(logn)
* /
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var searchInsert = function(nums, target) {
if (target <= nums[0]) {
return 0;
}
let left = 0;
let right = nums.length - 1;
let m = Math.floor((left + right) / 2)
while (left < right) {
if (target < nums[m]) {
right = m - 1;
} else if (target > nums[m]) {
left = m + 1;
} else if (target === nums[m]) {
return m;
}
m = Math.floor((left + right) / 2)
}
if (left === right) {
if (nums[left] === target) {
return left;
} else if (nums[left] < target) {
return left + 1;
} else {
// num[left] > target
if(nums[left - 1] < target) {
return left;
} else {
return left === 0 ? 0 : left - 1;
}
}
} else {
// left > right
return right + 1;
}
};
TiPS: 代码中存在一处问题。也就是
left+right可能越界
。所以更好的等价写法是left+ (right-left) / 2
,不会出现越界的情况。在js底层所有数值都由浮点型存储表示,根据IEEE754规范
(见附录知识点),最大能表示的数值范围也就是[-2^53, 2^53 - 1]
🐱🚀 三、滑动窗口(Sliding window)
滑动窗口,可以抽象理解为存在一个大小可变的窗口,通过控制左右两端的大小和移动的步调来达到解决一定性质的问题(如子序列)。左右两端一般都是向前移动,如左边固定,右边左移;右边固定,左边右移。
滑动窗口法,可以用来解决一些查找满足一定条件的连续区间的性质的问题
。由于区间连续,因此当区间发生变化时,可以通过旧有的计算结果对搜索空间进行剪枝,这样便减少了重复计算,降低了时间复杂度。
如下图所示:
如果一个题目出现定长
、连续
关键字时就可以考虑滑动窗口了。
👪 3.1 长度最小的子数组
(中等) (leetcode 209 🚀) 给定一个含有 n
个正整数的数组和一个正整数 target
找出该数组中满足其和 ≥ target
的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr]
,并返回其长度 。 如果不存在符合条件的子数组,返回 0
。
示例 1:
输入: target = 7, nums = [2,3,1,2,4,3]
输出: 2
解释: 子数组 [4,3] 是该条件下的长度最小的子数组。
示例 2:
输入: target = 4, nums = [1,4,4]
输出: 1
示例 3:
输入: target = 11, nums = [1,1,1,1,1,1,1,1]
输出: 0
提示:
1 <= target <= 109
1 <= nums.length <= 105
1 <= nums[i] <= 105
核心思想
-
- 初始窗口长度为1, 进入第2步
-
-
如果未遍历完成,记录和sum,长度,按以下规则处理。如果遍历完成就进入第3步。
2.1 如果 sum < target, 那么窗口需要右扩,放入下一个元素。进入下一个循环
2.2 如果 sum >= target, 那么窗口需要右移,舍弃最左边元素。判断是否是最小长度,是的话放入min中。进入下一个循环。
-
-
- 循环完成返回min
如图所示
代码如下所示
function slidingWindow(target, nums) {
let res = Infinity;
let len = nums.length;
for (let i = 0, j = 0, cur = 0; i < len; i++) {
//累加,对应2.1中的右扩
cur += nums[i];
//判断并移动左边
while (cur >= target) {
//更新res
res = Math.min(res, i - j + 1);
// 去除前面j这个元素之后的和
// 对应2.2中的舍弃最左边的元素
cur -= nums[j];
j++;
}
}
//判断是否一直没有更新res,没有更新就返回0
return res === Infinity ? 0 : res;
}
🦁 3.2 定长子串中元音的最大数目
(中等) (leetcode 1456 🚀)给你字符串 s
和整数 k
。
请返回字符串 s
中长度为 k
的单个子字符串中可能包含的最大元音字母数。
英文中的 元音字母 为(a
, e
, i
, o
, u
)。
示例 1:
输入: s = "abciiidef", k = 3
输出: 3
解释: 子字符串 "iii" 包含 3 个元音字母。
示例 2:
输入: s = "aeiou", k = 2
输出: 2
解释: 任意长度为 2 的子字符串都包含 2 个元音字母。
示例 3:
输入: s = "leetcode", k = 3
输出: 2
解释: "lee"、"eet" 和 "ode" 都包含 2 个元音字母。
示例 4:
输入: s = "rhythms", k = 4
输出: 0
解释: 字符串 s 中不含任何元音字母。
示例 5:
输入: s = "tryhard", k = 4
输出: 1
提示:
1 <= s.length <= 10^5
s
由小写英文字母组成1 <= k <= s.length
核心思想
初始窗口的长度为k, 判断初始窗口元音的数量。窗口右移,舍弃左边元素,如果最左边元素是元音,元音数量需要减一。放入下一个元素,如果是元音,元音数量需要加一
-
初始窗口长度为k。
-
第一个循环遍历初始窗口
0 <= i < k
,保存元音数量到count中。result等于max(count,result) -
第二个循环遍历每一次窗口移动
k <= i <= s.length
;如果最左边的元素即
s[i-k]
属于元音 count--;如果当前元素s[i] 属于元音,那么count++;
reult = max(count, result)
-
return result即可
代码如下所示:
var maxVowels = function(s, k) {
if(!s || s.length === 0 || s.length < k) return 0;
let result = 0;
let hashSet = new Set(['a', 'e', 'i', 'o', 'u']);
let count = 0;
// 第一个窗口
for (let i = 0; i < k; i++) {
if (hashSet.has(s[i])) count++;
}
result = Math.max(result, count);
// 移动窗口了需要
for (let i = k; i < s.length; i++) {
// 舍弃最左边的元素,如果是元音count需要-1。
if (hashSet.has(s[i - k])) count--;
// 放入下一个元素, 如果是元音,count需要+1
if (hashSet.has(s[i])) {
count++;
}
result = Math.max(result, count);
// 如果最大长度等于k了,进行剪枝,减少执行次数
if (result === k) {
break;
}
}
return result;
};
时间复杂度:O(n)
只对输入数组进行了一次遍历。
空间复杂度:O(1)
,hashSet虽然占用了存储空间,但是并不随着输入数据的长度进行变化,它占用的空间始终一样。
💤 四、递归
递归(Recursion)可以通俗的一句话理解:函数间接或者直接的调用自己。
在解决问题中通常需要结合DFS
、分治法
、回溯法
、分治法
形成更复杂的算法。
在使用递归算法解决问题时,需要确定以下几个要素:
- 接受的参数
- 返回值
- 递归结束的条件
- 递归拆解:怎么进入下一次递归
我们可以看如下求斐波拉契数列的第n项的值得递归代码:
TIPS
凡是使用递归的算法,他的空间复杂度都不是O(1),因为递归存在一个递归栈。怎么确定递归栈的复杂度呢,往下看
递归算法的空间复杂度计算方法
通常是递归树的高度。斐波拉契递归算法的空间复杂度,如下图所示
💍 4.1 反转链表
(简单) (leetcode 206 🚀) 给你单链表的头节点 head
,请你反转链表,并返回反转后的链表。
示例 1:
输入: head = [1,2,3,4,5]
输出: [5,4,3,2,1]
示例 2:
输入: head = [1,2]
输出: [2,1]
示例 3:
输入: head = []
输出: []
提示:
- 链表中节点的数目范围是
[0, 5000]
-5000 <= Node.val <= 5000
核心思想
递归求解,找出递归的四要素即可。
- 入参head;
- 结束条件为!head || !head.next; 返回值为head;
- 递归拆解为R(head.next); 一层层递归下去,在一层一层返回的时候操作head->next->next的指向即可,每一次都把head->next置位空。 看下图深入理解
如图所示
代码如下所示
function recurisive(head) {
// cornercase
if(!head || !head.next) {
return head; // 返回值
}
const p = recurisive(head.next); // 递归拆解
head.next.next = head; // 在递归栈返回层次开始反转操作
head.next = null;
return p;
}
时间复杂度: O(n) 只对输入数据进行了一次遍历。
空间复杂度:O(n) 主要是递归栈的开销。
💫 4.2 反转字符串
(简单)(leetcode 344 🚀)编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s
的形式给出。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
示例 1:
输入: s = ["h","e","l","l","o"]
输出: ["o","l","l","e","h"]
示例 2:
输入: s = ["H","a","n","n","a","h"]
输出: ["h","a","n","n","a","H"]
提示:
1 <= s.length <= 105
s[i]
都是 ASCII 码表中的可打印字符
核心思想
采用双指针,一个在前一个在尾,然后进行互换。递归参数为str,left,right;递归结束条件为left>=right;递归返回值为str;递归下一次left+1,right-1;
如图所示
代码如下所示
function reverseStr(s, left, right) {
if (left >= right) return s;
let t = s[left]
s[left] = s[right]
s[right] = t
return fc1(s, left + 1, right - 1)
}
时间复杂度:O(n),只对输入数据进行了一次遍历 空间复杂度:O(n),主要是递归栈的开销。
🗺️ 五、分治法
分治法(Divide and Conquer)就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础。如归并排序、快速排序算法。 分治法所能解决的问题一般具有以下几个特征:
-
(1)该问题的规模缩小到一定的程度就可以容易地解决
-
(2)该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。
-
(3)利用该问题分解出的子问题的解可以合并为该问题的解;
-
(4)该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
📯 5.1 寻找多数元素
给定一个大小为 n
的数组 nums
,返回其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊ n/2 ⌋
的元素。
你可以假设数组是非空的,并且给定的数组总是存在多数元素。
示例 1:
输入: nums = [3,2,3]
输出: 3
示例 2:
输入: nums = [2,2,1,1,1,2,2]
输出: 2
提示:
n == nums.length
1 <= n <= 5 * 104
-109 <= nums[i] <= 109
核心思想
利用递归求解。如果把一个区间从中分为左右两个区间,如果a是多数元素,那么它一定存在于左区间或者右区间。把区间划分为左右区间递归,直到所有的子区间都是长度为 1 的数组。长度为 1 的子数组中唯一的数显然是多数元素,直接返回即可。如果回溯后某区间的长度大于 1,我们必须将左右子区间的多数元素值合并。如果它们的众数相同,那么显然这一段区间的众数是它们相同的值。否则,我们需要比较两个众数在整个区间内出现的次数来决定该区间的众数。
如图所示
- 划分子区间图
- 动态图
- 静态图
代码如下所示
function fc3(nums) {
if (nums.length === 1) return nums[0];
return DivideAndConquer(nums, 0 , nums.length - 1)
}
function DivideAndConquer(nums, low, high) {
// CornerCase
if (low === high) {
// 只有一个元素返回该元素
return nums[low]
}
let mid = Math.floor(low + (high - low) / 2)
// 获取左边区间的多数元素
let left = DivideAndConquer(nums, low, mid)
// 获取右边的多数元素
let right = DivideAndConquer(nums, mid + 1, high)
// 如果两边多数元素是同一个元素就随机取一个
if (left === right) {
return left;
}
// 否则不相同,就要看两个区间合并,真正出现的次数多一点的多数元素
let leftCount = countInRange(nums, left, low, high)
let rightCount = countInRange(nums, right, low, high)
return leftCount > rightCount ? left : right;
}
时间复杂度:会求解2个长度为 的子问题,并做两遍长度为 n 的线性扫描。因此,分治算法的时间复杂度可以表示为: = O(nlogn)
根据 主定理,本题满足第二种情况,所以时间复杂度可以表示为:
空间复杂度:O(nlogn) 即递归树的高度,从静态图中可知每次递归都将数组分为了两部分,因此在将数组长度变为1时需要logn次。
🧲 5.2 最大子数组和
(leetcode 53 🚀)给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
示例 1:
输入: nums = [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6 。
示例 2:
输入: nums = [1]
输出: 1
示例 3:
输入: nums = [5,4,-1,7,8]
输出: 23
提示:
1 <= nums.length <= 105
-104 <= nums[i] <= 104
进阶: 如果你已经实现复杂度为 O(n)
的解法,尝试使用更为精妙的 分治法 求解。
核心思想
分治实现,利用递归完成。把数组分为左右两端区域进行递归,当递归长度为1时,最大和就是该元素。对于一个区间的最大和:取左区间的最大和、右区间的最大和,左右区间所有元素加起来的和。三者之间的最大值。一直向上和并即可得出问题的解。
如图所示:
- 划分图
- 动态图
- 最终结果
代码如下
function fc2(nums) {
return getMax(nums, 0, nums.length - 1);
}
function getMax(nums, left, right) {
if (left === right) { // 结束条件
return nums[left];
}
let mid = Math.floor(left + (right - left) / 2);
let leftMax = getMax(nums, left, mid);
let rightMax = getMax(nums, mid + 1, right);
let crossMax = getCrossMax(nums, left, right);
return Math.max(leftMax, rightMax, crossMax);
}
function getCrossMax (nums, left, right) {
let mid = Math.floor((left + right) / 2);
let leftSum = nums[mid];
let leftMax = leftSum;
for (let i = mid ; i > left; i--) {
leftSum += nums[i];
leftMax = Math.max(leftMax, leftSum)
}
let rightSum = nums[mid + 1];
let rightMax = rightSum;
for (let i = mid + 1; i < right; i++) {
rightSum += nums[i];
rightMax = Math.max(rightMax, rightSum)
}
return leftSum + rightSum;
}
时间复杂度:O(nlogn), 求解了2个子问题,并做了一次长度为n的线性扫描。T(n)=2T()+n = O(nlogn)
空间复杂度:O(logn) 即递归的次数,递归的区间长度为1需要进行logN次划分区间。
♻️ 六、回溯法
回溯法(BackTracking)是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走
的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。
🍉 6.1 子集
(leetcode 78 🚪)给你一个整数数组 nums
,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入: nums = [1,2,3]
输出: [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入: nums = [0]
输出: [[],[0]]
提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums
中的所有元素 互不相同
核心思想
空集和集合本身是任何集合的子集。依次枚举长度为1,到n的所有子集。采用回溯法求解即可。长度为1的子集有[1],[2],[3], 长度为2的子集有[1,2],[1,3],[2,3],长度为3的子集有[1,2,3]。
如图所示:
代码如下:
function subsets(nums) {
const result = [[]];
const backTraking = function(nums,result, begin, len,list = []) {
if (list.length === len) {
result.push([...list]) // copy list,防止引用传递
return;
}
// 这里的i代表nums的下标
for (let i = begin; i < nums.length; i++) {
list.push(nums[i]);
backTraking(nums, result, i + 1, len, list)
list.pop()
}
}
// 长度从1-n生成的排列
for (let i = 1; i <= nums.length; i++) {
backTraking(nums, result, 0, i, [])
}
return result;
}
时间复杂度:O(n* ), 一共个子集,每个状态需要每种状态需要 O(n) 的时间来构造子集 空间复杂度:O(n) 即递归栈的开销。
👸🏻 6.2 解救公主
(困难)给定一个5*5
的迷宫,每个网格上方格的坐标为 (x, y)
。
现在从源方格 source = [sx, sy]
开始出发,意图赶往公主所在方格 target = [tx, ty]
。数组 blocked
是封锁的方格列表,其中每个 blocked[i] = [xi, yi]
表示坐标为 (xi, yi)
的方格是禁止通行的。
每次移动,都可以走到网格中在四个方向上相邻的方格,只要该方格 不 在给出的封锁列表 blocked
上。同时,不允许走出网格。
只有在可以通过一系列的移动从源方格 source
到达目标方格 target
时,输出所有路径
与所有的最短路径
、最短路径长度
示例 1:
输入: blocked = [[0,1],[1,0]], source = [0,0], target = [0,2]
输出:
解释:
从源方格无法到达目标方格,因为我们无法在网格中移动。
无法向北或者向东移动是因为方格禁止通行。
无法向南或者向西移动是因为不能走出网格。
提示:
0 <= blocked.length <= 25
blocked[i].length == 2
0 <= xi, yi < 5
source.length == target.length == 2
0 <= sx, sy, tx, ty < 5
source != target
- 题目数据保证
source
和target
不在封锁列表内
核心思想
题目简化为从原点(sx,sy)到公主所在目标(tx,ty); 可以向上下左右四个方向遍历,用一个hasMap记录(x,y)坐标以走,防止重复回溯。
结束条件:sx<0||sy<0 || sx > 5 || sy > 5 || (sx,sy)被锁住 不可走, 返回上一层。
当sx=tx && sy = ty 时, 保存当前路径,返回上一层
如图所示
代码如下
var isEscapePossible = function(blocked, source, target) {
let blockSet = new Set(); // 保存哪些坐标被锁住了 x#y
let hasMap = {}; // 保存x,y是否走过
if (blocked.length === 0) return true;
blocked.forEach(([x, y]) => {
blockSet.add(`${x}#${y}`)
})
let [sx, sy] = source
let [tx, ty] = target
// 存储所有的路径
let result = []
backTracking(sx, sy, tx,ty, blockSet, hasMap, result, [])
return result
};
/**
*
* @param {number} sx // 原点x坐标
* @param {number} sy // 原点y坐标
* @param {number} tx // 公主所在x坐标
* @param {number} ty // 公主所在y坐标
* @param {number[][]} blockSet // 哪些点不可走 形式:x#y
* @param {Record<string, number>} hasMap 哪些点已走 形式:x#y: 0 不可走,1 可走
* @param {string[][]} result 保存所有路径数组
* @param {string} list 保存一条路径
* @returns {Boolean} 返回值标明公主所在坐标是否可达
*/
function backTracking(sx,sy, tx,ty, blockSet, hasMap, result, list) {
if (sx < 0 || sy < 0 || sx > 4 || sy > 4 || (blockSet.has(`${sx}#${sy}`))) return false;
if (sx === tx && sy === ty) {
list.push(`(${sx},${sy})`)
result.push([...list])
list.pop()
return true;
}
// 如果当前坐标已经走过剪枝
if (hasMap[`${sx}#${sy}`] === 0) return false;
// 记录路径
list.push(`(${sx},${sy})`)
// 标明当前坐标已经走过,防止重复回溯
hasMap[`${sx}#${sy}`] = 0
let up = backTracking(sx - 1, sy, tx, ty, blockSet, hasMap, result, list)
let right = backTracking(sx, sy + 1, tx, ty, blockSet, hasMap, result, list)
let down = backTracking(sx + 1, sy, tx, ty, blockSet, hasMap, result, list)
let left = backTracking(sx - 1, sy, tx, ty, blockSet, hasMap, result, list)
// 标明此坐标可走
hasMap[`${sx}#${sy}`] = 1
// 删除当前下标尝试下一个坐标点
list.pop();
return left || right || up || down
}
测试代码如下
onst blocked = [[0,3],[1,3], [2,3], [2,2], [2,1], [2,1]]
const source = [0,0]
const target = [4,4]
const result = isEscapePossible(blocked, source, target)
console.log('所有路径为:========================')
result.forEach((item,index) => {
console.log(`路径${index+1}, 路径长度为${item.length}`)
console.log(item.join('->'))
})
console.log('===========================')
let minPathLength = Number.MAX_SAFE_INTEGER;
result.forEach(item => {
if (item.length < minPathLength) {
minPathLength = item.length
}
})
console.log('最短路径长度为:', minPathLength)
let shotestPaths = result.filter(item => item.length === minPathLength)
console.log('最短路径为:')
shotestPaths.forEach(item => {
console.log(item.join('->'))
})
console.log('--------------------------------');
输出结果如下
所有路径为:========================
路径1, 路径长度为9
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(3,2)->(3,3)->(3,4)->(4,4)
路径2, 路径长度为9
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(3,2)->(3,3)->(4,3)->(4,4)
路径3, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(3,2)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
路径4, 路径长度为9
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(3,2)->(4,2)->(4,3)->(4,4)
路径5, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(3,2)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
路径6, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(4,1)->(4,2)->(3,2)->(3,3)->(3,4)->(4,4)
路径7, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(4,1)->(4,2)->(3,2)->(3,3)->(4,3)->(4,4)
路径8, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(4,1)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
路径9, 路径长度为9
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(4,1)->(4,2)->(4,3)->(4,4)
路径10, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(4,1)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
路径11, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(4,1)->(4,2)->(3,2)->(3,3)->(3,4)->(4,4)
路径12, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(4,1)->(4,2)->(3,2)->(3,3)->(4,3)->(4,4)
路径13, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(3,3)->(3,4)->(4,4)
路径14, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(3,3)->(4,3)->(4,4)
路径15, 路径长度为13
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
路径16, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(4,2)->(4,3)->(4,4)
路径17, 路径长度为13
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
路径18, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(4,2)->(3,2)->(3,3)->(3,4)->(4,4)
路径19, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(4,2)->(3,2)->(3,3)->(4,3)->(4,4)
路径20, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
路径21, 路径长度为9
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(4,2)->(4,3)->(4,4)
路径22, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
路径23, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(4,2)->(3,2)->(3,3)->(3,4)->(4,4)
路径24, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(4,2)->(3,2)->(3,3)->(4,3)->(4,4)
路径25, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(3,3)->(3,4)->(4,4)
路径26, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(3,3)->(4,3)->(4,4)
路径27, 路径长度为13
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
路径28, 路径长度为11
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(4,2)->(4,3)->(4,4)
路径29, 路径长度为13
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(3,1)->(3,2)->(4,2)->(4,3)->(3,3)->(3,4)->(4,4)
===========================
最短路径长度为: 9
最短路径为:
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(3,2)->(3,3)->(3,4)->(4,4)
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(3,2)->(3,3)->(4,3)->(4,4)
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(3,2)->(4,2)->(4,3)->(4,4)
(0,0)->(1,0)->(2,0)->(3,0)->(3,1)->(4,1)->(4,2)->(4,3)->(4,4)
(0,0)->(1,0)->(2,0)->(3,0)->(4,0)->(4,1)->(4,2)->(4,3)->(4,4)
--------------------------------
🌊 七、深度优先搜索(DFS)
深度优先搜索即从顶层往下访问,先往下访问,直到最底层,然后再去访问下一个分支。如下图所示
首先遍历路径为:A-------B------------E,到最底层了回溯到A,遍历下一个分支
然后遍历:A------------C-----------F, 到最底层了回溯到A,遍历下一个分支
然后遍历到:A----------D-----------G, 回溯到A,没有新的分支,遍历完成。
主要应用于二叉树的搜索、图的搜索等场景。
有的童鞋发现,DFS算法和回溯算法很相似,很容易混淆,它们有什么区别?
作者观点就是:回溯 = DFS + 剪枝
🌴 7.1 二叉树的中序遍历
(简单)(leetcode 94 🚀)给定一个二叉树的根节点 root
,返回 它的 中序 遍历 。
示例 1:
输入: root = [1,null,2,3]
输出: [1,3,2]
示例 2:
输入: root = []
输出: []
示例 3:
输入: root = [1]
输出: [1]
提示:
- 树中节点数目在范围
[0, 100]
内 -100 <= Node.val <= 100
代码如下
function fc1(root) {
const result = []
const DFS = function (root) {
if (root === null) {
return;
}
DFS(root.left)
result.push(root.val)
DFS(root.right)
}
DFS(root)
return result;
}
时间复杂度:O(n), 所有节点都会遍历一次,因此为O(n) 。
空间复杂度: O(n), 当节点为一条链时,递归栈的深度就是n。
🏝️ 7.2 岛屿的数量
给你一个由 '1'
(陆地)和 '0'
(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
示例 1:
输入: grid = [ ["1","1","1","1","0"],
["1","1","0","1","0"],
["1","1","0","0","0"],
["0","0","0","0","0"]
]
输出: 1
示例 2:
输入: grid = [ ["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]
]
输出: 3
提示:
m == grid.length
n == grid[i].length
1 <= m, n <= 300
grid[i][j]
的值为'0'
或'1'
核心思想
- 从0,0开始寻找到一个1;标记为找到一个大陆。
- 然后向上下左右是个方向继续寻找1;把找到的1改为0,直到找不到新的1了。
- 从0,1开始寻找,找到新的1,大陆+1,然后向4个方向找,直到找不到新的1了。
- 继续按上面的逻辑寻找。
代码如下
function fc1(grid) {
let result = 0;
if (grid === null || grid.length === 0) {
return 0;
}
const rows = grid.length;
const cols = grid[0].length;
const DFS = function (i, j) {
if (i < 0 || j < 0 || i >= rows || j >= cols || grid[i][j] === "0") return;
grid[i][j] = "0";
DFS(i - 1, j); // 上
DFS(i, j + 1); // 右
DFS(i + 1, j); // 下
DFS(i, j - 1); // 左
};
for (let i = 0; i < rows; i++)
for (let j = 0; j < cols; j++) {
if (grid[i][j] === "1") {
result++;
DFS(i, j);
}
}
return result;
}
时间复杂度:O(MN) ,M为行数,N为列数,即每个元素被遍历一遍。
空间复杂度:O(MN), 最坏情况下所有都为陆地。即递归栈的开销。
🚔 八、广度优先遍历(BFS)
广度优先遍历算法(Breadth First Search)又称宽度遍历算法或层序遍历算法。主要思想是层层递进,一层一层进行遍历。主要应用于二叉树的搜索、图的搜索中。通常会借用一个队列来存放展开的节点。
与深度遍历算法的对比
深度优先搜索用栈(stack)来实现,整个过程可以想象成一个倒立的树形:
1、把根节点压入栈中。
2、每次从栈中弹出一个元素,搜索所有在它下一级的元素,把这些元素压入栈中。并把这个元素记为它下一级元素的前驱。
3、找到所要找的元素时结束程序。
4、如果遍历整个树还没有找到,结束程序。
广度优先搜索使用队列(queue)来实现,整个过程也可以看做一个倒立的树形:
1、把根节点放到队列的末尾。
2、每次从队列的头部取出一个元素,查看这个元素所有的下一级元素,把它们放到队列的末尾。并把这个元素记为它下一级元素的前驱。
3、找到所要找的元素时结束程序。
4、如果遍历整个树还没有找到,结束程序。
伪代码实现
queue = [root]
while(queue.length) {
len = queue.size // 获取此层队列的长度
while(len) { // 此wile(是遍历此层的所有元素
const val = queue.pop();
if (条件满足)
queue.unshift(val.left); 如果条件满足放入到队列中
if (条件2满足)
queue.unshift(val.right); 如果条件满足放入到队列中
// TODO 对值进行保存或者操作
//...
len --
}
🌡️ 8.1 二叉树的层序遍历
(中等)leetcode 102 🚀 给你二叉树的根节点 root
,返回其节点值的 层序遍历 。 (即逐层地,从左到右访问所有节点)。
示例 1:
输入: root = [3,9,20,null,null,15,7]
输出: [[3],[9,20],[15,7]]
示例 2:
输入: root = [1]
输出: [[1]]
示例 3:
输入: root = []
输出: []
提示:
- 树中节点数目在范围
[0, 2000]
内 -1000 <= Node.val <= 1000
核心思想
采用BFS。初始化一个队列,根元素在队列中。 如果队列不为空,记录队列的大小。然后根据队列的大小,出队列相应的次数,可以理解为取当前层的元素。读取队列最后一个元素,然后如果拥有左孩子,那么将左孩子加入到队列;右孩子同理。然后对取出的节点的值加入到结果数组即可。
如图所示
代码如下
function BFS (root, result) {
const queue = [];
root && queue.push(root);
while(queue.length) { // 开启新的层级
let len = queue.length
const list = []
while (len > 0) { // 遍历此层所有元素
const cur = queue.pop();
list.push(cur.val)
if (cur.left) {
queue.unshift(cur.left)
}
if (cur.right) {
queue.unshift(cur.right)
}
len--;
}
result.push([...list])
}
}
时间复杂度: O(n), 每个元素只进队出队一次,可以理解为所有元素只会被遍历一次。
空间复杂度:O(n)队列中元素的个数不超过 n 个,故空间复杂度为 O(n)。
🤑 九、贪心算法
贪心算法(Greedy)指在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,算法得到的是在某种意义上的局部最优解 。
贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择 。
算法思路
贪心算法一般按如下步骤进行:
-
1.建立数学模型来描述问题
-
2.把求解的问题分成若干个子问题
-
3.对每个子问题求解,得到子问题的局部最优解
-
4.把子问题的解局部最优解合成原来解问题的一个解
💰 9.1 零钱兑换问题
(中等)(leetcode 322 🚀)给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例 2:
输入: coins = [2], amount = 3
输出: -1
示例 3:
输入: coins = [1], amount = 0
输出: 0
提示:
1 <= coins.length <= 12
1 <= coins[i] <= 231 - 1
0 <= amount <= 104
核心思想
贪心算法在当前每一步都是最优的。因此每次我们都要选择在可选的最大面值的硬币。如下图所示
硬币数组[1,2,5], 余额11
次数 | 选择的硬币 | 剩余余额 |
---|---|---|
1 | 5 | 6 |
2 | 5 | 1 |
3 | 1 | 0 |
硬币数组[3,5], 余额11
次数 | 选择的硬币 | 剩余余额 |
---|---|---|
1 | 5 | 6 |
2 | 5 | 1 |
金额只剩1了,但是没有面值为1的硬币,所以贪心算法就得不到整体的最优解。所以这儿在加个回溯就可以解决问题了。
即先考虑用n个最大面额硬币, 剩余金额在用剩余面额硬币兑换; 再考虑用n-1个最大面额硬币, 剩余金额在用剩余面额硬币兑换...依次类推。 再进行剪枝, 若已用数量已大于最小数量, 则无续再往下讨论。
代码如下所示:
var coinChange = function(coins, amount) {
coins.sort((a, b) => b - a) // coins降序排列
let res = Infinity // 保存最小硬币数量
/**
*
* @param {*} amount 目标金额
* @param {*} index 硬币数组下标
* @param {*} count 已使用的硬币数量
* @returns
*/
const dfs = (amount, index, count) => {
// 金额为0,代表有解。
if (amount === 0) return res = Math.min(res, count)
// 金币面额都使用到最后一个了,代表无解;
if (index === coins.length) return
// n为可使用的最大面额硬币的最大数量, 在使用n-1个最大面额硬币,在使用n-2个...直到0
for (let n = amount / coins[index] | 0; count + n < res && n >= 0; n--)
dfs(amount - n * coins[index], index + 1, count + n)
}
dfs(amount, 0, 0)
return res === Infinity ? -1 : res
};
tips
amount / coins[index] 计算最大能投几个硬币
amount - n * coins[c_index] 减去扔了 n 个硬币之后的余额
count + n 已使用硬币+n个
🎮 9.2 跳跳游戏
(中等)(leetcode 55 🚀)给定一个非负整数数组 nums
,你最初位于数组的 第一个下标 。
数组中的每个元素代表你在该位置可以跳跃的最大长度。 判断你是否能够到达最后一个下标。
示例 1:
输入: nums = [2,3,1,1,4]
输出: true
解释: 可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
示例 2:
输入: nums = [3,2,1,0,4]
输出: false
解释: 无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。
提示:
1 <= nums.length <= 3 * 104
0 <= nums[i] <= 105
核心思想
设想一下,对于数组中的任意一个位置 y,我们如何判断它是否可以到达?根据题目的描述,只要存在一个位置 x,它本身可以到达,并且它跳跃的最大长度为 x+nums[x],这个值大于等于 y,那么y就可达了
这样以来,我们依次遍历数组中的每一个位置,并实时维护最远可以到达的位置。对于当前遍历到的位置 x,如果它在最远可以到达的位置的范围内,那么我们就可以从起点通过若干次跳跃到达该位置,因此我们可以用 x+nums[x]更新 最远可以到达的位置。
代码如下所示
var canJump = function(nums) {
let n = nums.length;
let rightmost = 0;
for (let i = 0; i < n; ++i) {
if (i <= rightmost) {
rightmost = Math.max(rightmost, i + nums[i]);
if (rightmost >= n - 1) {
// 如果能达到最后一格,直接返回true
return true;
}
}
}
return false;
};
时间复杂度: O(n),只对输入对数组进行一次遍历
空间复杂度: O(1),无额外使用空间。
🕹️ 十、动态规划
动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策过程最优化的过程,被广泛的用于算法求最优解中(参考百度百科)。我自己的理解就是一个大问题可以化成若干个小问题,得到小问题 的解就可以构造出大问题的解,是一个自底向上的过程。通常有一个缓存数组来缓存重复的子问题的解,只要以后遇到就返回该值来提升算法的效率。
过程通常是:先求解最小的子问题,把结果存在表格中,在求解大的子问题时,直接从表格中查询小的子问题的解,避免重复计算,从而提高算法效率。
与分治法的区别
与分治法不同
的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立
的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案
,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案
。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。
动态规划的解往往包含子问题的解, 而分治法则不包括。
动态规划的要素
通常我们使用动态规划算法时,需要找出以下几个“名词”
- 状态转移方程 通常为左式随着右式变化的动态式。表述为: F(n) = F(n-1) + F(n-2)(以阶梯问题为例) ;
- 最优子结构 继续以上例论述:最优子结构为右式F(n-1)+F(n-2)
- 边界值 继续以阶梯问题论述,当n为1时 F(1) = 1(只能一次走完),F(2) = 2(要么一次走完;要么分两次,一次走一介);边界值为F(1)和F(2)。
使用场景
一切拥有最优子结构的问题都可以用动态规划算法求解。
🕯️ 10.1 斐波那契数
(leetcode 509 🚀)斐波那契数(通常用 F(n)
表示)形成的序列称为 斐波那契数列 。该数列由 0
和 1
开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n
,请计算 F(n)
。
示例 1:
输入: n = 2
输出: 1
解释: F(2) = F(1) + F(0) = 1 + 0 = 1
示例 2:
输入: n = 3
输出: 2
解释: F(3) = F(2) + F(1) = 1 + 1 = 2
示例 3:
输入: n = 4
输出: 3
解释: F(4) = F(3) + F(2) = 2 + 1 = 3
提示:
0 <= n <= 30
动态规划要素如下
- 1.最优子结构: F(n-1)+F(n-2)
- 2.状态转移方程:F(n) = F(n-1)+F(n-2)
- 3.边界值:F(0) = 0, F(1) = 1
代码如下所示
var fib = function(n) {
let dp = [0,1]
for (let i = 2; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2]
}
return dp[n]
};
时间复杂度 O(n) ,从2到n遍历。
空间复杂度 O(n) 即保存子问题的解数组dp的开销。
🥻 10.2 寻找不同路径
(中等)(leetcode 62 🚀)一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例 1:
输入: m = 3, n = 7
输出: 28
示例 2:
输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下
示例 3:
输入: m = 7, n = 3
输出: 28
示例 4:
输入: m = 3, n = 3
输出: 6
提示:
1 <= m, n <= 100
- 题目数据保证答案小于等于
2 * 109
我们首先寻找一下最优子结构,问题最小化,在2x2的网格中。原点到左下角有2条路径,如图所示。
在3*2的网格中。原点到左下角有3条路径,如图所示。
综上所述,我们可以发现最优子结构,对于任意的格子P(x,y),由原点到此格子的路径等于
F(x, y-1) + F(x-1, y)
。动态规划的三要素如下
最优子结构
状态转移方程
边界值: x =0 或y=0
F(0,y) = 1, F(x,0) = 1, F(0,0) = 1
核心思想:
用动态规划的思想解决问题,使用一个数组dp保存到P(x,y)途径点所存在的路径条数。先初始化所有的边界值,然后根据任意点的路径等于左边加上边的点的路径条数来求解。
Tips
二维数组转一维数组:
一维转二维: , x%cols]
代码如下
var uniquePaths = function(m, n) {
let dp = []
for(let index = 0; index < m*n; index++) {
const rows = index / n | 0; // 得到二维横坐标
const cols = index % n; // 得到二维纵坐标
if (rows === 0 || cols === 0) {
dp[index] = 1
} else {
let upIndex = (rows - 1) * n + cols;
dp[index] = dp[upIndex] + dp[index - 1]; // 左边加右边
}
}
return dp[dp.length - 1]
};
时间复杂度:O(mn), 即dp数组的长度
空间复杂度: O(mn), 即dp数组的长度。
🏫 十一、并查集
并查集(Union Find),在一些有N个元素的集合应用问题中,我们通常是在开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪个集合中。
并查集是一种树型的数据结构,用于处理一些不相交集合(disjoint sets)的合并及查询问题。常常在使用中以森林来表示。
主要操作
初始化 (init)
把每个点所在集合初始化根元素为其自身。
通常来说,这个步骤在每次使用该数据结构时只需要执行一次,无论何种实现方式,时间复杂度均为O(n)
查找 (Find)
查找元素所在的集合,即根节点。
合并 (Union)
将两个元素所在的集合合并为一个集合。
通常来说,合并之前,应先判断两个元素是否属于同一集合,这可用上面的“查找”操作实现
举例,对于如下图所示的森林进行并查集的主要操作。
(1) 初始化之后 把每个点的根元素初始化为其自身。
(2)合并之后的结果为。
Union(1,7)之后的结果为
(3)查找
查找元素6的根节点为:Find(6)
-
节点6的父元素为3, 不等于index, 因此需要Find(3)
-
节点3的父元素为1,不等于index, 需要Find(1)
-
节点1的父元素为1,等于index,返回1,因此6的根节点为1.
Union类模板
class Union {
root = []
constructor(n) {
this.root = new Array(n+1);
this._init(n);
}
// 初始化
_init (n) {
for (let i = 0; i <= n; i++)
this.root[i] = i;
}
find (x) {
if (root[x] === x) {
return x;
}
return this.find(root[x])
}
union(x,y) {
let rootX = this.find(x)
let rootY = this.find(y)
if (rootX !== rootY) {
this.root[rootY] = rootX
}
}
}
并查集优化
普通查找节点5的跟元素需要4次,节点4的根元素需要3次,随着树的高度,查询时间层直线上升
。
快速查找
QuickFind的核心思想就是减少查询次数
如查询节点5的需要走到4,3,1;那么我们可以在递归返回的时候修改节点4,3的父元素为1。即可提升下次查询节点4,节点3的时间。
快速合并
QuickUnion是一种权重法,核心思想减少树的高度。比较两个需要连接的树的高度,把矮的树连接到高树上去,使树的高度最低。
如果现在要把节点5跟节点6连起来
普通合并就是直接设置6的父元素为5,下次查询11的时间需要9个节点的耗时。
快速合并做得就是比较他们的树的高度,谁的高度大,谁就是父元素,小的为其子元素。如图所示
优化后的Union类模板如下
优化Union类
class Union {
root = []
rank = []
constructor(n) {
this.root = new Array(n+1);
this._init(n);
}
// 初始化
_init (n) {
for (let i = 0; i <= n; i++)
this.root[i] = i;
this.rank[i] = 0;
}
find (x) {
if (root[x] === x) {
return x;
}
return root[x] = this.find(root[x])
}
union(x,y) {
let rootX = this.find(x)
let rootY = this.find(y)
if (rootX !== rootY) {
if (this.rank[rootX] > this.rank[rootY]) {
this.root[rootY] = rootX
} else if (this.rank[rootX] < this.rank[rootY]) {
this.root[rootX] = rootY
} else {
this.root[rootY] = rootX
this.rank[rootX] += 1
}
}
}
}
🏄 11.1 岛屿数量
(leetcode 200 🚀) 给你一个由 '1'
(陆地)和 '0'
(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
示例 1:
输入: grid = [ ["1","1","1","1","0"],
["1","1","0","1","0"],
["1","1","0","0","0"],
["0","0","0","0","0"]
]
输出: 1
示例 2:
输入: grid = [ ["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]
]
输出: 3
提示:
m == grid.length
n == grid[i].length
1 <= m, n <= 300
grid[i][j]
的值为'0'
或'1'
核心思想
用并查集求解。首先从(0,0)遍历寻找一个1,然后看它的上下左右,如果为1,进行同化(Union)操作,记录同化次数,相同的祖先元素不算一次同化;如果为0那么记录水域数量; 遍历到结束。
陆地的数量
=格子数(row*col)
-水域数量(为0的格子
)-同化次数
如图所示
代码如下所示
class UnionFind {
root = [];
rank = [];
count = 0; // 保存同化的次数
constructor(n) {
this.root = new Array(n + 1);
this._init(n);
}
// 初始化
_init(n) {
for (let i = 0; i <= n; i++) {
this.root[i] = i;
this.rank[i] = 0;
}
}
find(x) {
if (this.root[x] === x) {
return x;
}
return (this.root[x] = this.find(this.root[x]));
}
union(x, y) {
let rootX = this.find(x);
let rootY = this.find(y);
if (rootX !== rootY) {
// 祖先元素不同,同化次数+1
this.count++;
if (this.rank[rootX] > this.rank[rootY]) {
this.root[rootY] = rootX;
} else if (this.rank[rootX] < this.rank[rootY]) {
this.root[rootX] = rootY;
} else {
this.root[rootY] = rootX;
this.rank[rootX] += 1;
}
}
}
getCount() {
return this.count;
}
}
function fc3(grid) {
if (grid === null || grid.length === 0) return 0;
let row = grid.length;
let col = grid[0].length;
let water = 0; // 保存水域数量
let uf = new UnionFind(row * col);
for (let i = 0; i < row; i++)
for (let j = 0; j < col; j++) {
if (grid[i][j] === "0") water++;
else {
directions = [
[0, 1],
[0, -1],
[1, 0],
[-1, 0],
];
for (let d of directions) {
let x = i + d[0];
let y = j + d[1];
if (x >= 0 && x < row && y >= 0 && y < col && grid[x][y] === "1") {
uf.union(x * col + y, i * col + j);
}
}
}
}
return row * col - uf.getCount() - water;
}
时间复杂度: O(mn) m为行数,n为列数;
空间复杂度: O(mn) 即并查集中存储父元素的一维数组长度。
💦 11.2 省份数量
(中等)(leetcode 547 🚀)有 n
个城市,其中一些彼此相连,另一些没有相连。如果城市 a
与城市 b
直接相连,且城市 b
与城市 c
直接相连,那么城市 a
与城市 c
间接相连。
省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。
给你一个 n x n
的矩阵 isConnected
,其中 isConnected[i][j] = 1
表示第 i
个城市和第 j
个城市直接相连,而 isConnected[i][j] = 0
表示二者不直接相连。
返回矩阵中 省份 的数量。 示例 1:
输入: isConnected = [[1,1,0],[1,1,0],[0,0,1]]
输出: 2
示例 2:
输入: isConnected = [[1,0,0],[0,1,0],[0,0,1]]
输出: 3
提示:
1 <= n <= 200
n == isConnected.length
n == isConnected[i].length
isConnected[i][j]
为1
或0
isConnected[i][i] == 1
isConnected[i][j] == isConnected[j][i]
核心思想
如果两个城市连通,那么合并。遍历到最后只需要计算合并次数。只需要遍历左上角元素即可。
如图所示:
代码如图所示:
class UnionFind {
root = [];
rank = [];
count = 0; // 保存同化的次数
constructor(n) {
this.root = new Array(n + 1);
this._init(n);
}
// 初始化
_init(n) {
for (let i = 0; i <= n; i++) {
this.root[i] = i;
this.rank[i] = 0;
}
}
find(x) {
if (this.root[x] === x) {
return x;
}
return (this.root[x] = this.find(this.root[x]));
}
union(x, y) {
let rootX = this.find(x);
let rootY = this.find(y);
if (rootX !== rootY) {
this.count++;
if (this.rank[rootX] > this.rank[rootY]) {
this.root[rootY] = rootX;
} else if (this.rank[rootX] < this.rank[rootY]) {
this.root[rootX] = rootY;
} else {
this.root[rootY] = rootX;
this.rank[rootX] += 1;
}
}
}
getCount() {
return this.count;
}
}
/**
* @param {number[][]} isConnected
* @return {number}
*/
var findCircleNum = function (isConnected) {
if (isConnected === null || isConnected.length === 0) return 0;
let rows = isConnected.length;
let uf = new UnionFind(rows);
for (let i = 0; i < rows; i++)
for (let j = i + 1; j < rows; j++) {
if (isConnected[i][j] === 1) {
uf.union(i, j);
}
}
return rows - uf.getCount();
};
时间复杂度:, 对输入数组进行了一次遍历。忽略了Union里面的查询和合并花费的时间了。取最高阶。
空间复杂度:O(n),其中 n 是城市的数量。即UnionFind类使用数组 记录每个城市所属的连通分量的祖先。
📝 十二、记忆化搜索(备忘录)
记忆化搜索又称备忘录(Memorization),严格来说并不能算作一个算法,是一种技巧。通常存储结果集,防止重复计算,提高执行效率。常用于递归中的剪枝、重复子问题的解存储等场景。
目的 减少重复计算,减少时间复杂度。
以斐波拉契数列为列,我们以递归形式实现。 伪代码
define function fc(n):
if n === 0 return 0
if n === 1 return 1
return fc(n-1) + fc(n-2)
递归树如下所示
我们可以发现会存在多次重复计算
, 我们应该把计算的结果存储起来,下次需要计算时取出即可。看优化后的伪代码
define function fc(n):
memorization = Array(n)
define function _inner(n):
if n === 0 return 0
if n === 1 return 1
if (!memorization[n]) {
result = _inner(n-1) + _inner(n-2)
memorization[n] = result
}
return memorization[n];
🌂 结语
本文中所诉算法只做基础的思想传递,可能定义和官方有所差别,因为每个人的理解不一。建议大家参考,可参照《算法导论》形成自己的理解。
本文可作为应试前的参考资料,认真理解每一个算法,可以解决面试中的大部分简单、中等题。
算法这个东西需要长时间的积累,建议leetcode专项练习。拥有一定的基础后,再适当减少做题数量,做做每日一题即可了。有兴趣的可以打打周赛。
如果本文所述的有不对的地方,欢迎提出,我第一时间进行修改。 也欢迎大家引用本文。
📁 附录
📈 1. 时间复杂度曲线图
🚩 2. IEEE 754规范
IEEE二进制浮点数算术标准(IEEE 754)是20世纪80年代以来最广泛使用的浮点数运算标准,为许多CPU与浮点运算器所采用。这个标准定义了表示浮点数的格式(包括负零-0)与反常值(denormal number)),一些特殊数值(无穷(Inf)与非数值(NaN)),以及这些数值的“浮点数运算符”;它也指明了四种数值舍入规则和五种例外状况(包括例外发生的时机与处理方式)。
IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。
也就是浮点数的实际值,等于符号位(sign bit)乘以指数偏移值(exponent bias)再乘以分数值(fraction)。
js中的数值表现形式
js中的数值在底层以浮点数存储表示,采用双精度存储,也就是8B(字节), 64bit(位).
第一位:表示符号位,0表示正数,1表示负数。
第2到11位:共10位,表示偏移值。
第12-53位:共53位,表示精度。
因此js中能表示的整形数值范围就是[-2^53, 2^53-1]
也就是 Number.MAX_SAFE_INTEGER
和Number.Min_SAFE_INTEGER
.
🙉 3.leetcode题目(1-300题)源代码
包括此博客中的所有算法代码
💜 参考文献
- 百度百科
- 维基百科
5. 水冰淼-动态规划