概述
搜索就是以某种顺序访问数据结构中的各个元素,包括
- 查找,即找到符合条件的即结束
- 遍历,保证对所有元素进行访问,通常每个元素只访问一遍
搜索过程中要按照一定顺序(比如深度优先或广度优先),且尽量减少不必要的步骤(剪枝),减少算法的复杂度。
这里会讨论常见的搜索方法
- 二分 搜索有序数组
- 双指针,用来处理数组和链表,还包括滑动窗口算法
- 深度优先搜索(DFS),搜索树或图,还包括回溯算法
- 广度优先搜索(BFS),搜索树或图,还包括拓扑排序
二分查找
二分查找适合有序的数组,如果通过迭代查找时间复杂度为o(n),通过二分,每次查找将查找范围减少一半,时间复杂度为o(lgn)。
基本用法
最常规的二分用法以Sqrt(x)为例
var mySqrt = function(x) {
let left=0,right=x
while(left<=right){
let mid=Math.floor((left+right)/2)
let cur=mid*mid,next=(mid+1)*(mid+1)
if(cur<=x&&next>x){
return mid
}else if(cur>x){
right=mid-1
}else{
left=mid+1
}
}
};
首先确定搜索的左右范围,并当符合条件时,判断中间值是否符合条件,如果中间值大于目标值说明目标值在左半区间,否则在右半区间。
这里说的"符合条件"推荐使用小于等于,如果没有等号会遗漏left===right的情况。
例题
二分的思想比较简单,但有很多变体,注意不要遗漏两个端点或进入死循环,另外的题比如
双指针
双指针指的是遍历数组或链表时,两个或多个指向该数据结构元素的指针,按一定规则进行移动,共同完成搜索任务。
如果两个指针沿同一个方向移动,这两个指针中间的范围被称为滑动窗口。
数组中的双指针
数组中的指针可以从任何位置开始,比如例题盛最多水的容器。
已知水的容量等于长*高,长等于两个指针对应下标的差,高等于两个位置值较小的一个。
解题思路为将两个指针初始化为left为0,right为最右端下标,计算容量,然后将两个指针向中间移动,计算并比较可能更大的容量结果,最终两指针相遇时返回最大结果
/**
* @param {number[]} height
* @return {number}
*/
var maxArea = function(height) {
let left=0,right=height.length-1,max=0
while(left<right){
let hLeft=height[left],hRight=height[right]
max=Math.max(max,Math.min(hLeft,hRight)*(right-left))
if(hLeft<hRight){
while(height[left]<=hLeft){
left++
}
}else if(hRight<hLeft){
while(height[right]<=hRight){
right--
}
}else{
while(height[left]<=hLeft){
left++
}
while(height[right]<=hRight){
right--
}
}
}
return max
};
链表中的双指针
链表的搜索只能从表头开始,使用的双指针通常称为快慢指针,快指针要么先出发,要么每次移动的步数比较多。
比如处理环形链表 II时,快指针每次两步,慢指针每次一步,如果最终相遇就一定存在环。
当相遇时,将慢指针移动至表头,快慢指针每次移动一步,相遇的点即入环点(证明见对应题解)。
再比如删除链表的倒数第 N 个结点,快指针先出发N步慢指针再出发,当快指针指向最后一个节点时,慢指针指向倒数N+1个,将该节点的next指向下一个的next节点即可。
var removeNthFromEnd = function(head, n) {
let res=fast=slow=new ListNode()
res.next=head
for(let i=0;i<n;i++){
fast=fast.next
}
while(fast?.next){
fast=fast.next
slow=slow.next
}
slow.next=slow.next.next
return res.next
};
因为可能会删除第一个节点,因此处理之前添加一个链头节点。
滑动窗口
滑动窗口需要利用两个指针维护一个一定范围的子数组,并在滑动过程中利用其它方法具体处理问题。
比如例题滑动窗口最大值需要找这个范围中的最大值,即
在窗口中维护一个降序数组。
滑动过程中,刚移出窗口范围的元素记为a,新加入窗口范围的元素记为b。
判断当前最大值是否为a,如果是则将其在子数组中移除,然后将子数组中小于b的元素移除,并将b加入。
重复以上范围直到窗口移动至数组结束。
例题无重复字符的最长子串中,用窗口保存无重复字符,为了判断滑动过程中的下一个是否重复可用散列表保存。
当不重复时就一直添加,如果遇到重复的字符,就应该移动滑动窗口的左指针到这个字符上次出现的下一个位置,因此散列表的key应为对应字符,value应记录其位置。
当每次移动left指针时,会有一些元素虽然已经不在滑动窗口了,但仍然在散列表中,比如abba,当迭代到第二个b时,left指针指向第二个b,下一次迭代中,虽然滑动窗口只有一个b,但仍然能在散列表找到,因此left指针应该为left和散列表保存位置的较大值。
var lengthOfLongestSubstring = function(s) {
let map=new Map(),left=0,max=0
for(let i=0;i<s.length;i++){
if(map.has(s[i])){
left=Math.max(left,map.get(s[i]))
}
map.set(s[i],i)
max=Math.max(max,i-left+1)
}
return max
};
深度优先搜索
即depth-first seach,DFS,从一个起点出发(比如树的根节点)沿着某个支路不断深入,到达端点后,再深度搜索其他分支,直到搜索到目标元素或遍历一遍。
深度优先搜索可以使用递归对每个分支进行实现,也可以手动维护递归栈。
树的深度优先搜索
这里只讨论二叉树,其他树类似。
一棵二叉树由根节点和两个子树组成,深度优先搜索通常讨论三种顺序
- 前序遍历 根左右
- 中序遍历 左根右
- 后续遍历 左右根
递归实现
function dfs(root) {
if (root) {
//在这里处理root节点,先序
dfs(root.left);
//在这里处理root节点,中序
dfs(root.right);
//在这里处理root节点,后序
}
}
非递归,先序和中序比较接近,即维护一个栈,从根节点开始将左子节点入栈,如果cyrrent为null,则出栈一个,然后从当前节点的右子树开始重复以上,直到栈空且current为null,即遍历完成
function traversalByStack(current) {
const stk = []
while (current || stk.length) {
while (current) {
// console.log(current.value); //先序
stk.push(current)
current = current.left
}
current = stk.pop()
// console.log(current.value); //中序
current = current.right
}
}
非递归的后序遍历不容易直接实现,可以将以上变形,即将右子节点不断入栈,然后将栈顶的元素出栈,将其左子树重复以上,得到一个根右左的遍历顺序,然后reverse。
function postOrderTraversal(current) {
const stk = [],
res = [];
while (current || stk.length) {
while (current) {
res.push(current.value);
stk.push(current);
current = current.right;
}
current = stk.pop().left;
}
return res.reverse();
}
遍历中的状态记录
在遍历过程中,为了防止重复访问需要对访问过得节点做标记,这就是状态记录或记忆化。
比如螺旋矩阵。
回溯
回溯是一种特殊的搜索,如果搜索到某个节点发现不符合要求,便可以回溯到之前的某个节点沿另一个分支进行搜索,同时要将状态回退到回溯到的情况。
回溯的状态处理过程是 [修改当前节点状态]→[递归子节点]→[回改当前节点状态]
比如全排列中,从0个元素开始与该元素本身及之后的元素交换,然后再对第1个处理,直到最后一个。
以[1,2,3]为例,当我们处理1开头的排列时获得了123,132,此时第1个和第2个已经交换了位置,为了不影响2开头的排列,应当在递归子节点后将位置恢复。
var permute = function(nums) {
let res=[]
backTrack(0)
return res
function backTrack(level){
if(level===nums.length){
return res.push(nums.concat())
}
for(let i=level;i<nums.length;i++){
[nums[i],nums[level]]=[nums[level],nums[i]]
backTrack(level+1);
[nums[i],nums[level]]=[nums[level],nums[i]]
}
}
};
在单词搜索时,遍历矩阵,当遇到单词首个字母与当前元素相同时,沿四个方向深度搜索,为避免重复搜索,对每个搜索过的元素进行标记。
在递归子节点后,将标记还原。
var exist = function (board, word) {
let sign = board.map((item) => item.concat()),
m = board.length,
n = board[0].length;
const dirs = [
[0, 1],
[1, 0],
[-1, 0],
[0, -1],
];
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (board[i][j] === word[0] && search(i, j, 0)) {
return true;
}
}
}
return false;
function search(i, j, level) {
if (level === word.length - 1) {
return true;
}
sign[i][j] = true;
for (let dir of dirs) {
let next = [i + dir[0], j + dir[1]];
if (
inArea(...next) &&
board[next[0]][next[1]] === word[level + 1] &&
sign[next[0]][next[1]] !== true &&
search(...next, level + 1)
) {
return true;
}
}
sign[i][j] = board[i][j];
}
function inArea(i, j) {
return i >= 0 && j >= 0 && i < m && j < n;
}
};
广度优先搜索
广度优先搜索对数据结构从离起点到远一层层遍历,实现过程中借助队列,将每一层的元素的下一个添加到下一轮处理的队列中。
树的广度优先搜索
将根元素加入队列,作为处理队列。
将队列中的元素一个个出队,然后将各自元素的下一个元素添加到新的队列中。
当处理队列为空时,将新队列置为处理队列,重复以上,直到处理队列为空。
function levelOrder(root) {
let res = [],
cur = [root]
while (cur.length) {
let curNodes = []
while (cur.length) {
let node = cur.shift()
res.push(node.value)
node.left && curNodes.push(node.left)
node.right && curNodes.push(node.right)
}
cur = curNodes
}
return res
}
遍历中的状态记录
广度优先遍历中的状态作用和深度遍历类似。
拓扑排序
是对有向无环图的广度优先遍历,遍历的结果是一个有序数组。
拓扑排序的起始节点入度为0,可能不止1一个,因此需要先找到所有起始点,然后依次寻找下一个节点,做好状态记录避免重复遍历,直到遍历结束。
如课程表 II中,先完成的课程作为一个节点指向后完成的课程,拓扑排序即得结果。