前端算法系列(8):搜索

322 阅读3分钟

概述

搜索就是以某种顺序访问数据结构中的各个元素,包括

  • 查找,即找到符合条件的即结束
  • 遍历,保证对所有元素进行访问,通常每个元素只访问一遍

搜索过程中要按照一定顺序(比如深度优先或广度优先),且尽量减少不必要的步骤(剪枝),减少算法的复杂度。

这里会讨论常见的搜索方法

  • 二分 搜索有序数组
  • 双指针,用来处理数组和链表,还包括滑动窗口算法
  • 深度优先搜索(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中,先完成的课程作为一个节点指向后完成的课程,拓扑排序即得结果。