双向 BFS 基本思路(含模板)以及两种「启发式搜索」算法

简介: 双向 BFS 基本思路(含模板)以及两种「启发式搜索」算法

网络异常,图片无法展示
|


题目描述



这是 LeetCode 上的 752. 打开转盘锁 ,难度为 中等


Tag : 「双向 BFS」、「启发式搜索」、「AStar 算法」、「IDAStar 算法」


你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有10个数字: '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' 。每个拨轮可以自由旋转:例如把 '9' 变为 '0','0' 变为 '9' 。每次旋转都只能旋转一个拨轮的一位数字。

锁的初始数字为 '0000' ,一个代表四个拨轮的数字的字符串。


列表 deadends 包含了一组死亡数字,一旦拨轮的数字和列表里的任何一个元素相同,这个锁将会被永久锁定,无法再被旋转。


字符串 target 代表可以解锁的数字,你需要给出解锁需要的最小旋转次数,如果无论如何不能解锁,返回 -1 。


示例 1:


输入:deadends = ["0201","0101","0102","1212","2002"], target = "0202"
输出:6
解释:
可能的移动序列为 "0000" -> "1000" -> "1100" -> "1200" -> "1201" -> "1202" -> "0202"。
注意 "0000" -> "0001" -> "0002" -> "0102" -> "0202" 这样的序列是不能解锁的,
因为当拨动到 "0102" 时这个锁就会被锁定。
复制代码


示例 2:


输入: deadends = ["8888"], target = "0009"
输出:1
解释:
把最后一位反向旋转一次即可 "0000" -> "0009"。
复制代码


示例 3:


输入: deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"], target = "8888"
输出:-1
解释:
无法旋转到目标数字且不被锁定。
复制代码


示例 4:


输入: deadends = ["0000"], target = "8888"
输出:-1
复制代码


提示:


  • 1 <= deadends.length <= 500
  • deadends[i].length == 4
  • target.length == 4
  • target 不在 deadends 之中
  • target 和 deadends[i] 仅由若干位数字组成


基本分析



首先,我建议你先做「127. 单词接龙」,然后再回过头将本题作为「练习题」。


「127. 单词接龙」定位困难,而本题定位中等。主要体现在数据范围上,思维难度上「127. 单词接龙」并不比本题难,大胆做。

「127. 单词接龙」原题链接在 这里,相关题解在 这里


回到本题,根据题意,可以确定这是一个「最短路」问题。


此类问题,通常我们会使用「BFS」求解,但朴素的 BFS 通常会带来搜索空间爆炸问题。


因此我们可以使用与 (题解)127. 单词接龙 类似的思路进行求解。


双向 BFS



我们知道,递归树的展开形式是一棵多阶树。


使用朴素 BFS 进行求解时,队列中最多会存在“两层”的搜索节点。


因此搜索空间的上界取决于 目标节点所在的搜索层次的深度所对应的宽度


下图展示了朴素 BFS 可能面临的搜索空间爆炸问题:


网络异常,图片无法展示
|


在朴素的 BFS 实现中,空间的瓶颈主要取决于搜索空间中的最大宽度。


那么有没有办法让我们不使用这么宽的搜索空间,同时又能保证搜索到目标结果呢?


「双向 BFS」 可以很好的解决这个问题:


同时从两个方向开始搜索,一旦搜索到相同的值,意味着找到了一条联通起点和终点的最短路径。


对于「有解」、「有一定数据范围」同时「层级节点数量以倍数或者指数级别增长」的情况,「双向 BFS」的搜索空间通常只有「朴素 BFS」的空间消耗的几百分之一,甚至几千分之一。


网络异常,图片无法展示
|


「双向 BFS」的基本实现思路如下:


  1. 创建「两个队列」分别用于两个
  2. 方向的搜索;
  3. 创建「两个哈希表」用于「解决相同节点重复搜索」和「记录转换次数」;
  4. 为了尽可能让两个搜索方向“平均”,每次从队列中取值进行扩展时,先判断哪个队列容量较少;
  5. 如果在搜索过程中「搜索到对方搜索过的节点」,说明找到了最短路径。


「双向 BFS」基本思路对应的伪代码大致如下:


d1、d2 为两个方向的队列
m1、m2 为两个方向的哈希表,记录每个节点距离起点的
// 只有两个队列都不空,才有必要继续往下搜索
// 如果其中一个队列空了,说明从某个方向搜到底都搜不到该方向的目标节点
while(!d1.isEmpty() && !d2.isEmpty()) {
    if (d1.size() < d2.size()) {
        update(d1, m1, m2);
    } else {
        update(d2, m2, m1);
    }
}
// update 为从队列 d 中取出一个元素进行「一次完整扩展」的逻辑
void update(Deque d, Map cur, Map other) {}
复制代码


回到本题,我们看看如何使用「双向 BFS」进行求解。


估计不少同学是第一次接触「双向 BFS」,因此这次我写了大量注释。


建议大家带着对「双向 BFS」的基本理解去阅读。


网络异常,图片无法展示
|


代码:


class Solution {
    String t, s;
    Set<String> set = new HashSet<>();
    public int openLock(String[] _ds, String _t) {
        s = "0000";
        t = _t;
        if (s.equals(t)) return 0;
        for (String d : _ds) set.add(d);
        if (set.contains(s)) return -1;
        int ans = bfs();
        return ans;
    }
    int bfs() {
        // d1 代表从起点 s 开始搜索(正向)
        // d2 代表从结尾 t 开始搜索(反向)
        Deque<String> d1 = new ArrayDeque<>(), d2 = new ArrayDeque<>();
        /*
         * m1 和 m2 分别记录两个方向出现的状态是经过多少次转换而来
         * e.g.s
         * m1 = {"1000":1} 代表 "1000" 由 s="0000" 替换 1 次字符而来
         * m2 = {"9999":3} 代表 "9999" 由 t="9996" 替换 3 次字符而来
         */
        Map<String, Integer> m1 = new HashMap<>(), m2 = new HashMap<>();
        d1.addLast(s);
        m1.put(s, 0);
        d2.addLast(t);
        m2.put(t, 0);
        /*
         * 只有两个队列都不空,才有必要继续往下搜索
         * 如果其中一个队列空了,说明从某个方向搜到底都搜不到该方向的目标节点
         * e.g. 
         * 例如,如果 d1 为空了,说明从 s 搜索到底都搜索不到 t,反向搜索也没必要进行了
         */
        while (!d1.isEmpty() && !d2.isEmpty()) {
            int t = -1;
            if (d1.size() <= d2.size()) {
                t = update(d1, m1, m2);
            } else {
                t = update(d2, m2, m1);
            }
            if (t != -1) return t;
        }
        return -1;       
    }
    int update(Deque<String> deque, Map<String, Integer> cur, Map<String, Integer> other) {
        String poll = deque.pollFirst();
        char[] pcs = poll.toCharArray();
        int step = cur.get(poll);
        // 枚举替换哪个字符
        for (int i = 0; i < 4; i++) {
            // 能「正向转」也能「反向转」,这里直接枚举偏移量 [-1,1] 然后跳过 0
            for (int j = -1; j <= 1; j++) {
                if (j == 0) continue;
                // 求得替换字符串 str
                int origin = pcs[i] - '0';
                int next = (origin + j) % 10;
                if (next == -1) next = 9;
                char[] clone = pcs.clone();
                clone[i] = (char)(next + '0');
                String str = String.valueOf(clone);
                if (set.contains(str)) continue;
                if (cur.containsKey(str)) continue;
                // 如果在「另一方向」找到过,说明找到了最短路,否则加入队列
                if (other.containsKey(str)) { 
                    return step + 1 + other.get(str);
                } else {
                    deque.addLast(str);
                    cur.put(str, step + 1);
                }
            }
        }
        return -1;
    }
}
复制代码


AStar 算法



可以直接根据本题规则来设计 A* 的「启发式函数」。


比如对于两个状态 ab 可直接计算出「理论最小转换次数」:不同字符的转换成本之和


需要注意的是:由于我们衡量某个字符 str 的估值是以目标字符串 target 为基准,因此我们只能确保 target 出队时为「距离最短」,而不能确保中间节点出队时「距离最短」,因此我们不能单纯根据某个节点是否「曾经入队」而决定是否入队,还要结合当前节点的「最小距离」是否被更新而决定是否入队。


这一点十分关键,在代码层面上体现在 map.get(str).step > poll.step + 1 的判断上。


注意:本题用 A* 过了,但通常我们需要先「确保有解」,A* 的启发搜索才会发挥真正价值。而本题,除非 t 本身在 deadends 中,其余情况我们无法很好提前判断「是否有解」。对于无解的情况 A* 效果不如「双向 BFS」。


网络异常,图片无法展示
|


代码:


class Solution {
    class Node {
        String str;
        int val, step;
       /**
        *  str : 对应字符串
        *  val : 估值(与目标字符串 target 的最小转换成本)
        *  step: 对应字符串是经过多少步转换而来
        */
        Node(String _str, int _val, int _step) {
            str = _str;
            val = _val;
            step = _step;
        }
    }
    int f(String str) {
        int ans = 0;
        for (int i = 0; i < 4; i++) {
            int cur = str.charAt(i) - '0', target = t.charAt(i) - '0';
            int a = Math.min(cur, target), b = Math.max(cur, target);
            // 在「正向转」和「反向转」之间取 min
            int min = Math.min(b - a, a + 10 - b);
            ans += min;
        }
        return ans;
    }
    String s, t;
    Set<String> set = new HashSet<>();
    public int openLock(String[] ds, String _t) {
        s = "0000";
        t = _t;
        if (s.equals(t)) return 0;
        for (String d : ds) set.add(d);
        if (set.contains(s)) return -1;
        PriorityQueue<Node> q = new PriorityQueue<>((a,b)->a.val-b.val);
        Map<String, Node> map = new HashMap<>();
        Node root = new Node(s, f(s), 0);
        q.add(root);
        map.put(s, root);
        while (!q.isEmpty()) {
            Node poll = q.poll();
            char[] pcs = poll.str.toCharArray();
            int step = poll.step;
            if (poll.str.equals(t)) return step;
            for (int i = 0; i < 4; i++) {
                for (int j = -1; j <= 1; j++) {
                    if (j == 0) continue;
                    int cur = pcs[i] - '0';
                    int next = (cur + j) % 10;
                    if (next == -1) next = 9;
                    char[] clone = pcs.clone();
                    clone[i] = (char)(next + '0');
                    String str = String.valueOf(clone);
                    if (set.contains(str)) continue;
                    // 如果 str 还没搜索过,或者 str 的「最短距离」被更新,则入队
                    if (!map.containsKey(str) || map.get(str).step > step + 1) {
                        Node node = new Node(str, step + 1 + f(str), step + 1);
                        map.put(str, node);
                        q.add(node);
                    }
                }
            }
        }
        return -1;
    }
}
复制代码


IDA* 算法



同样我们可以使用基于 DFS 的启发式 IDA* 算法:


  • 仍然使用 f() 作为估值函数
  • 利用旋转次数有限:总旋转次数不会超过某个阈值 \maxmax
  • 利用「迭代加深」的思路找到最短距离


理想情况下,由于存在正向旋转和反向旋转,每一位转轮从任意数字开始到达任意数字,消耗次数不会超过 55 次,因此理想情况下可以设定 \max = 5 * 4max=54


但考虑 deadends 的存在,我们需要将 \maxmax 定义得更加保守一些:\max = 10 * 4max=104


但这样的阈值设定,加上 IDA* 算法每次会重复遍历「距离小于与目标节点距离」的所有节点,会有很大的 TLE 风险。


因此我们需要使用动态阈值:不再使用固定的阈值,而是利用 target 计算出「最大的转移成本」作为我们的「最深数量级」。


PS. 上述的阈值分析是科学做法。对于本题可以利用数据弱,直接使用 \max = 5 * 4max=54 也可以通过,并且效果不错。


但必须清楚 \max = 5 * 4max=54 可能是一个错误的阈值,本题起点为 0000,考虑将所有正向转换的状态都放入 deadends 中,target2222。这时候我们可以只限定 0000 先变为 9999 再往回变为 2222 的通路不在 deadends 中。


这时候使用 \max = 5 * 4max=54 就不对,但本题数据弱,可以通过(想提交错误数据拿积分吗?别试了,我已经提交了 🤣


网络异常,图片无法展示
|


代码:


class Solution {
    String s, t;
    String cur;
    Set<String> set = new HashSet<>();
    Map<String, Integer> map = new HashMap<>();
    public int openLock(String[] ds, String _t) {
        s = "0000";
        t = _t;
        if (s.equals(t)) return 0;
        for (String d : ds) set.add(d);
        if (set.contains(s)) return -1;   
        int depth = 0, max = getMax();
        cur = s;
        map.put(cur, 0);
        while (depth <= max && !dfs(0, depth)) {
            map.clear();
            cur = s;
            map.put(cur, 0);
            depth++;
        }
        return depth > max ? -1 : depth;
    }
    int getMax() {
        int ans = 0;
        for (int i = 0; i < 4; i++) {
            int origin = s.charAt(i) - '0', next = t.charAt(i) - '0';
            int a = Math.min(origin, next), b = Math.max(origin, next);
            int max = Math.max(b - a, a + 10 - b);
            ans += max;
        }
        return ans;
    }
    int f() {
        int ans = 0;
        for (int i = 0; i < 4; i++) {
            int origin = cur.charAt(i) - '0', next = t.charAt(i) - '0';
            int a = Math.min(origin, next), b = Math.max(origin, next);
            int min = Math.min(b - a, a + 10 - b);
            ans += min;
        }
        return ans;
    }
    boolean dfs(int u, int max) {
        if (u + f() > max) return false;
        if (f() == 0) return true;
        String backup = cur;
        char[] cs = cur.toCharArray();
        for (int i = 0; i < 4; i++) {
            for (int j = -1; j <= 1; j++) {
                if (j == 0) continue;
                int origin = cs[i] - '0';
                int next = (origin + j) % 10;
                if (next == -1) next = 9;
                char[] clone = cs.clone();
                clone[i] = (char)(next + '0');
                String str = String.valueOf(clone);  
                if (set.contains(str)) continue;
                if (!map.containsKey(str) || map.get(str) > u + 1) {
                    cur = str;
                    map.put(str, u + 1);
                    if (dfs(u + 1, max)) return true;
                    cur = backup;
                }
            }
        }
        return false;
    }
}
复制代码


最后



这是我们「刷穿 LeetCode」系列文章的第 No.751 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先将所有不带锁的题目刷完。


在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。


为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…


在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。

相关文章
|
1月前
|
算法 搜索推荐 数据库
二分搜索:高效的查找算法
【10月更文挑战第29天】通过对二分搜索的深入研究和应用,我们可以不断挖掘其潜力,为各种复杂问题提供高效的解决方案。相信在未来的科技发展中,二分搜索将继续发挥着重要的作用,为我们的生活和工作带来更多的便利和创新。
50 1
|
2月前
|
算法 决策智能
基于禁忌搜索算法的VRP问题求解matlab仿真,带GUI界面,可设置参数
该程序基于禁忌搜索算法求解车辆路径问题(VRP),使用MATLAB2022a版本实现,并带有GUI界面。用户可通过界面设置参数并查看结果。禁忌搜索算法通过迭代改进当前解,并利用记忆机制避免陷入局部最优。程序包含初始化、定义邻域结构、设置禁忌列表等步骤,最终输出最优路径和相关数据图表。
|
3月前
|
大数据 UED 开发者
实战演练:利用Python的Trie树优化搜索算法,性能飙升不是梦!
在数据密集型应用中,高效搜索算法至关重要。Trie树(前缀树/字典树)通过优化字符串处理和搜索效率成为理想选择。本文通过Python实战演示Trie树构建与应用,显著提升搜索性能。Trie树利用公共前缀减少查询时间,支持快速插入、删除和搜索。以下为简单示例代码,展示如何构建及使用Trie树进行搜索与前缀匹配,适用于自动补全、拼写检查等场景,助力提升应用性能与用户体验。
67 2
|
2月前
|
机器学习/深度学习 存储 算法
数据结构与算法——BFS(广度优先搜索)
数据结构与算法——BFS(广度优先搜索)
|
2月前
|
存储 算法 C++
【搜索算法】 跳马问题(C/C++)
【搜索算法】 跳马问题(C/C++)
|
2月前
|
人工智能 算法 Java
【搜索算法】数字游戏(C/C++)
【搜索算法】数字游戏(C/C++)
|
4月前
|
存储 算法
BFS算法的实现
BFS算法的实现
55 1
|
4月前
|
算法
【算法】递归、搜索与回溯——汉诺塔
【算法】递归、搜索与回溯——汉诺塔
|
15天前
|
算法
基于WOA算法的SVDD参数寻优matlab仿真
该程序利用鲸鱼优化算法(WOA)对支持向量数据描述(SVDD)模型的参数进行优化,以提高数据分类的准确性。通过MATLAB2022A实现,展示了不同信噪比(SNR)下模型的分类误差。WOA通过模拟鲸鱼捕食行为,动态调整SVDD参数,如惩罚因子C和核函数参数γ,以寻找最优参数组合,增强模型的鲁棒性和泛化能力。
|
1天前
|
供应链 算法 调度
排队算法的matlab仿真,带GUI界面
该程序使用MATLAB 2022A版本实现排队算法的仿真,并带有GUI界面。程序支持单队列单服务台、单队列多服务台和多队列多服务台三种排队方式。核心函数`func_mms2`通过模拟到达时间和服务时间,计算阻塞率和利用率。排队论研究系统中顾客和服务台的交互行为,广泛应用于通信网络、生产调度和服务行业等领域,旨在优化系统性能,减少等待时间,提高资源利用率。
下一篇
DataWorks