主题3 “寻友之旅” 的两种解决办法!

246 阅读8分钟

当青训营遇上码上掘金!

“寻友之旅”的两种解决办法!

本文将分别讲解如何使用BFS以及 Dijsktra堆优化的方法来解决此题~ 一起来看看吧!

附Java题解代码!


前言

拿到这道题时,我们被小青有两种交通方式可选:步行和公交。(前进一步、后退一步、直达2 * current)这句话吸引了,可能首先想到用动态规划来做。 似乎好像大概是没问题的,那我们根据动态规划的思想:"当前的结果都是由前面的结果推导出来的"进行推导,举例:例如小码的地点在200这个结点(即终点下标为200),我们可以画出的示意图如下图所示:

graph TD
200 --> 100
200 --> 199
200 --> 201

100 --> 50
100 --> 99
100 --> 101

199 --> 198
199 --> 200

201 --> 200
201 --> 202

我们会发现一个问题!!!200可以由199推导过来,而199同样可以由200推导过来,emmm......这不成环了吗?其实,动态规划是拓扑图,一般来说是由当前状态推出下一状态,是无环的! ~好家伙,那这题咋做呢?经过一些思考,我们会想到用搜索来做,dfs和bfs里面,我选bfs,因为dfs每次都要搜到底,容易爆栈,而且里面的控制条件写起来相对bfs可能要更难一些,所以我们尝试用bfs来解此题

BFS题解

bfs找最短,最多就是100000的空间,vis记录一下已经访问过的点

dfs是找到底,可能会爆栈和超时

这个题相当于一维走迷宫,前进一步、后退一步、前进到两倍下标的地方,

二维走迷宫是上下左右走而已

以start为出发点,每次弹出元素时时先记录当前队列中元素的数量,这些数量就是以上一次访问到的点作为起点找出的其他能够访问的点的数量,方便后面弹出指定数量的元素;同时用vis[]来记录已经访问过的点,由于流程是逐层递进的,所以每个初次访问到的点所在的时间一定是最短时间!(举个例子:y点最初通过x点访问到,在后续的情况中,可能还会通过其他点再次访问到该点,而此时的时间一定是大于等于初次访问到y点的。)

代码如下(java):

import java.io.*;
import java.util.*;

public class Main {
    static int n, k;
    static boolean[] vis = new boolean[100001];

    public static void main(String[] args) throws IOException {
        //例如输入:20 38   输出 2
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        String[] line = br.readLine().split(" ");
        n = Integer.parseInt(line[0]);
        k = Integer.parseInt(line[1]);
        if (n == k)
            System.out.println(0);
        else if (n < k) {
            System.out.println(bfs(n, k));
        } else {
            System.out.println(n - k);
        }

    }

    private static int bfs(int start, int target) {
        int time = 0;
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(start);
        vis[start] = true;
        while (!queue.isEmpty()) {
            int length = queue.size();
            for (int i = 0; i < length; i++) {
                Integer current = -1;
                if (!queue.isEmpty())
                    current = queue.poll();
                if (current == target) {
                    return time;
                }
                if (current - 1 >= 0 && !vis[current - 1]) {
                    queue.offer(current - 1);
                    vis[current - 1] = true;
                }
                if (current + 1 <= 100000 && !vis[current + 1]) {
                    queue.offer(current + 1);
                    vis[current + 1] = true;
                }
                if (current * 2 <= 100000 && !vis[current * 2]) {
                    queue.offer(current * 2);
                    vis[current * 2] = true;
                }
            }
            time++;
        }
        return -1;
    }

}

dijsktra(堆优化)题解

我们把题目中的时间想象成路径,求“最短时间”是不是就变成了求“最短路径”问题了?(不过此题较为特殊,因为所有的移动方式所花费的时间都为1,即所有边的长度都为1。)

但是我们需要注意样例范围:(0≤N , K≤100 000)

朴素算法的时间复杂度是n²,而一般题目给的数据都是差不多le5,这时候肯定会爆

于是乎,我们想到用堆优化来降低时间复杂度,将时间复杂度从n²降到nlogn+m

堆优化了每次找离起点最近的点的时间复杂度

用“链式前向星”来创建图(如果不清楚这种建图方式可以先看下文部分【什么是“链式前向星”?】)

代码如下(java):

import java.io.*;
import java.util.*;

public class Main {
    static int[] head, next, ends;
    static int[] times;//结果
    static int n = 100000, m = 300000;//最多有n个顶点,m条边
    static int start, target, total = 0;//++total:从第一条边到最后一条边

    public static void main(String[] args) throws IOException {
        //例如输入:20 38   输出 2
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        String[] line = br.readLine().split(" ");
        start = Integer.parseInt(line[0]);
        target = Integer.parseInt(line[1]);
        if (start == target)
            System.out.println(0);
        else if (start < target) {
            head = new int[m + 1];//表示以 i 为起点的最后一条边的编号
            next = new int[m + 1];//存储与当前边起点相同的上一条边的编号
            ends = new int[m + 1];//存储边的终点
            times = new int[n + 1];
            Arrays.fill(head, -1);//初始化
            for (int i = 0; i <= n; i++) {
                if (i - 1 >= 0) add(i, i - 1);
                if (i + 1 <= n) add(i, i + 1);
                if (i * 2 <= n) add(i, i * 2);
            }
            dijkstra(start);
            System.out.println(times[target]);
        } else {
            System.out.println(start - target);
        }
    }

    private static void dijkstra(int startPoint) {
        for (int i = 1; i <= n; i++) {
            times[i] = Integer.MAX_VALUE;//初始化时,应当赋上最坏的情况
        }
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(startPoint);
        times[startPoint] = 0;//起始位置,应当赋上最好的情况
        while (!queue.isEmpty()) {
            int x = queue.poll();//当前点
            //链式前向星的遍历方法,遍历出以x为起点的所有边
            for (int i = head[x]; i != -1; i = next[i]) {//i表示:第 i 条边
                int j = ends[i];//第 i 条边的终点
                if (times[j] > times[x] + 1) {//如果length(起点-->终点) > length(起点 --> 当前点) + length(当前点 --> 终点)
                    times[j] = times[x] + 1;//更新起点到终点的最短距离
                    queue.offer(j);//并将这个终点入队,以便之后通过该点访问其他顶点
                }
            }
        }
    }

    private static void add(int start, int end) {
        ends[++total] = end;
        next[total] = head[start];//以start为起点的上一条边的编号,即:与这个边起点相同的上一条边的编号
        head[start] = total;//更新以start为起点的上一条边的编号
    }

}

或许有同学会不太清楚上述的建图方式,这里单独讲一下 ↓

什么是“链式前向星”?

如果说邻接表是不好写但效率好,邻接矩阵是好写但效率低的话,前向星就是一个相对中庸的数据结构。前向星固然好写,但效率并不高。而在优化为链式前向星后,效率也得到了较大的提升。虽然说,世界上对链式前向星的使用并不是很广泛,但在不愿意写复杂的邻接表的情况下,链式前向星也是一个很优秀的数据结构。 ——摘自《百度百科》

链式前向星其实就是静态建立的邻接表;
时间效率为O(n)、空间效率也为O(n)、遍历效率也为O(n);
对于下面的数据:第一行5个顶点、7条边。接下来是边的起点,终点和权值。如:边1 -> 2 权值为1。

5 7 
1 2 1 
2 3 2 
3 4 3 
1 3 4
4 1 5
1 5 6
4 5 7

*链式前向星存的是以【1,n】为起点的边的集合,对于上面的数据输出就是:

1 //以1为起点的边的集合
1 5 6 
1 3 4 
1 2 1 

2 //以2为起点的边的集合 
2 3 2

3 //以3为起点的边的集合 
3 4 3

4 //以4为起点的边的集合
4 5 7
4 1 5 

5 //以5为起点的边不存在

我们先对上面的7条边进行编号第一条边是0以此类推编号【0~6】。
然后我们要知道两个变量的含义:

  • Next,表示与这个边起点相同的上一条边的编号。
  • head[ i ]数组,表示以 i 为起点的最后一条边的编号。

head数组一般初始化为-1,  为什么是 -1后面会讲到。加边函数是这样的:

//java版本 
static int total = 0;//++total:记录从第一条边到最后一条边 
private static void add(int start, int end, long weight) {//链式前向星的创建方法 
    ends[++total] = end; 
    weights[total] = weight; 
    next[total] = head[start];//以start为起点的上一条边的编号,即:与这个边起点相同的上一条边的编号 
    head[start] = total;//更新以start为起点的上一条边的编号 
}

我们只要知道next,head数组表示的含义,根据上面的数据就可以写出下面的过程: 对于1 2 1这条边:end[0] = 2; next [0] = -1; head[1] = 0;

对于2 3 2这条边:end[1]= 3; next [1]= -1; head[2] = 1;

对于3 4 3这条边:end[2] = 4; next [2]= -1; head[3] = 2;

对于1 3 4这条边:end[3] = 3; next [3]= 0; head[1] = 3;

对于4 1 5这条边:end[4] = 1; next [4]= -1; head[4] = 4;

对于1 5 6这条边:end[5] = 5; next [5]= 3; head[1] = 5;

对于4 5 7这条边:end[6] = 5; next [6]= 4; head[4] = 6;

遍历函数是这样的:

//java版本
static int[] head;//表示以 i 为起点的最后一条边的编号
static int[] next;//存储与当前边起点相同的上一条边的编号
static int[] ends;//存储边的终点
static long[] weights;//权值

//链式前向星的遍历方法,遍历出以x为起点的所有边
for (int i = head[x]; i != -1; i = next[i]) {//i表示:第 i 条边
    System.out.println(i + "这条边的终点:" + ends[i] + "这条边的权值:" + weights[i]);
}
/**
第一层for循环是找每一个点,依次遍历以【1,n】为起点的边的集合。
第二层for循环是遍历以 i 为起点的所有边,k首先等于head[ i ],
注意head[ i ]中存的是以 i 为起点的最后一条边的编号。
然后通过next[ j ]来找下一条边的编号。我们初始化head为-1,
所以找到你最后一个边(也就是以 i 为起点的第一条边)时,
你的next[ j ]为 -1作为终止条件。
*/

现在再回头去看代码,是不是更容易理解了呢?(* ̄︶ ̄)

相关的题还有:蓝桥王国,评论区里面有我的题解 ↓ 欢迎大家来踩踩~

image.png


总结

两种方法都能做出此题(或许还有更多方法),但是我们必须思考:如果这个题变个形————比如把第二个条件改一下:“小青可以在times[x]分钟内从任意节点X移动到节点2*X ”,那这个题就变成了一个带权的图,用bfs就不太行了。bfs就是特殊的最短路,边权为1的最短路可以用bfs,而堆优化可以有效降低朴素dijsktra的时间复杂度,OI必备,希望大家仔细理解后将其掌握!

大家还有其他解法吗?欢迎在评论区留言讨论!~

文章粗浅,原创不易~ 如果本文对大家有帮助的话,希望可以点赞支持下~~~ (* ̄︶ ̄)