您的位置:首页 > 理论基础 > 数据结构算法

leetcode【数据结构简介】《队列&栈》卡片——队列和广度优先搜索

2020-04-21 21:30 447 查看

Authur Whywait 做一块努力吸收知识的海绵
想看博主的其他所有leetcode卡片学习笔记链接?传送门点这儿

先决条件:树的层序遍历

广度优先搜索(BFS)是一种遍历或搜索数据结构(如树或图)的算法。

如前所述,我们可以使用 BFS 在树中执行层序遍历。
我们也可以使用 BFS 遍历图。例如,我们可以使用 BFS 找到从起始结点到目标结点的路径,特别是最短路径
我们可以在更抽象的情景中使用 BFS 遍历所有可能的状态。在这种情况下,我们可以把状态看作是图中的结点,而以合法的过渡路径作为图中的边。

本文中,我们将简要介绍 BFS 是如何工作的,并着重关注队列如何帮助我们实现 BFS 算法。还将提供一些练习,关于自行设计和实现 BFS 算法,相信通过以下练习的训练,你可以非常好的掌握BFS算法,并将之实现。

队列和广度优先搜索

  • 相关编程练习
  • 2. 打开转盘锁
  • 3. 完全平方数
  • 总解
  • 队列和BFS

    广度优先搜索(BFS)的一个常见应用找出从根结点到目标结点的最短路径

    1. 结点的处理顺序

    第一轮中,我们处理根结点。在第二轮中,我们处理根结点旁边的结点;在第三轮中,我们处理距根结点两步的结点;等等等等。

    与树的层序遍历类似,越是接近根结点的结点将越早地遍历

    如果在

    第 k 轮中将结点 X 添加到队列中,则根结点与 X 之间的最短路径的长度恰好是 k
    。也就是说,第一次找到目标结点时,你已经处于最短路径中。

    2. 队列的入队和出队顺序

    我们首先将根结点排入队列。然后在每一轮中,我们逐个处理已经在队列中的结点,并将所有邻居添加到队列中。值得注意的是,新添加的节点不会立即遍历,而是在下一轮中处理

    结点的处理顺序与它们添加到队列的顺序是完全相同的顺序,即先进先出(FIFO)。这就是我们在 BFS 中使用队列的原因。

    广度优先搜索 - 模板

    前文已经介绍了使用 BFS 的两个主要方案:

    遍历
    找出最短路径
    。通常,这发生在树或图中。正如我们在章节描述中提到的,BFS 也可以用于更抽象的场景中。

    在特定问题中执行 BFS 之前确定

    结点
    边缘
    非常重要。
    通常,结点将是实际结点或是状态,而边缘将是实际边缘或可能的转换。

    模板一

    下面给出Java的伪代码作为模板

    /**
    * Return the length of the shortest path between root and target node.
    */
    int BFS(Node root, Node target) {
    Queue<Node> queue;  // store all nodes which are waiting to be processed
    int step = 0;       // number of steps neeeded from root to current node
    // initialize
    add root to queue;
    // BFS
    while (queue is not empty) {
    step = step + 1;
    // iterate the nodes which are already in the queue
    int size = queue.size();
    for (int i = 0; i < size; ++i) {
    Node cur = the first node in queue;
    return step if cur is target;
    for (Node next : the neighbors of cur) {
    add next to queue;
    }
    remove the first node from queue;
    }
    }
    return -1;          // there is no path from root to target
    }
    1. 如代码所示,在每一轮中,队列中的结点是等待处理的结点。
    2. 在每个更外一层的 while 循环之后,我们距离根结点更远一步。变量 step 指示从根结点到我们正在访问的当前结点的距离。

    模板二

    有时,确保我们永远

    不会访问一个结点两次
    很重要。否则,我们可能陷入无限循环。如果是这样,我们可以在上面的代码中添加一个哈希集来解决这个问题。这是修改后的伪代码:

    有没有觉得很懵逼?是不是在脑袋里搜寻哈希集这个名词而无果?如果没有这种情况,忽略这段话;如果有,且听我娓娓道来:
    其实哈希集于这题就是一个存储 曾入队列或者正在队列中的元素 的集合。
    如果在某一个节点的邻居不在哈希集里面,就允许其入队列,反之,不让它入队列。哈希集在这个程序的作用就像是一个“过滤器”。
    至于哈希集更加详细的知识,待日后进度到了,我就会写一系列相关博客,关于其基础知识以及相关编程练习以及详细注释。

    /**
    * Return the length of the shortest path between root and target node.
    */
    int BFS(Node root, Node target) {
    Queue<Node> queue;  // store all nodes which are waiting to be processed
    Set<Node> used;     // store all the used nodes
    int step = 0;       // number of steps neeeded from root to current node
    // initialize
    add root to queue;
    add root to used;
    // BFS
    while (queue is not empty) {
    step = step + 1;
    // iterate the nodes which are already in the queue
    int size = queue.size();
    for (int i = 0; i < size; ++i) {
    Node cur = the first node in queue;
    return step if cur is target;
    for (Node next : the neighbors of cur) {
    if (next is not in used) {
    add next to queue;
    add next to used;
    }
    }
    remove the first node from queue;
    }
    }
    return -1;          // there is no path from root to target
    }

    有两种情况你不需要使用哈希集:
    1.你完全确定没有循环,例如,在树遍历中;
    2.你确实希望多次将结点添加到队列中。

    相关编程练习

    1. 岛屿数量

    给定一个由

    '1'
    (陆地)和
    '0'
    (水)组成的的二维网格,计算岛屿的数量。一个岛被水包围,并且它是通过水平方向或垂直方向上相邻的陆地连接而成的。你可以假设网格的四个边均被水包围。

    输入:
    11110
    11010
    11000
    00000
    输出: 1

    输入:
    11000
    11000
    00100
    00011
    输出: 3

    分析

    看完题目,黑人问号脸.jpg是我想到的第一个东西:这题目和BFS有啥关系啊···

    但是仔细一想,关系是有的,而且不小:如果两个点属于同一个岛屿,那么他们就一定可以在一轮又一轮的广度优先搜索中被搜索到。等到广度优先搜索结束,就说明我们找到了一个岛屿上的所有点。然后我们再开启下一个岛屿的广度优先搜索之旅··· ···

    具体步骤

    步骤一:建立一个结构体Node,里面的x和y分别为横纵坐标。

    其实也不一定要先定义一个结构体,因为我们可以利用编号。举个例子说,55的矩阵中,坐标为(2,4)的点,可以用25+4=14代替;坐标为(1,3)的点,可以用1*5+3=8来代替。但是,在后面上下左右移动的时候,条件的判断就会略显麻烦,所以定义一个横纵坐标的结构体会更加方便一些。

    步骤二:定义一个 4*2 的二维数组。用来存储移动方向-上下左右。
    步骤三: 定义一个队列。
    步骤四:开始循环,并用计数器count记录循环次数。每次循环先初始化头尾指针,然后找到一个非零的点,开始BFS,直至BFS结束,进入下一个循环。

    前文中我们有使用哈希集来确保我们的不会让元素重复入队列。在本程序中,我们直接让进入队列queue的点的值直接变为零,这样子在下次寻找结点的陆地邻居的时候就会自然而然的忽略原来的点(原来为陆地的点会被识别为海洋)。 这样子做的好处是代码量会少一些,但是不足之处就是改变了原来的二维矩阵(等程序运行到最后的时候,原地图上就只有海洋一片陆地都没有了)。

    解决方式有如下几种:

    1. 开始循环之前深拷贝原二维字符数组;
    2. 使用哈希集(这也是之前模板中所用到的方法);
    3. 使用一个数组或者是队列或者是其他的适用的数据结构,对所有陆地坐标进行记录,在最后返回计数器之前对原二维字符数组进行复原;
    4. 让经历过的结点的值为2而非0(这样子的方法有个好处,恢复的时候只要把非0的全部赋值为1就能恢复原矩阵了,也不需要各种数据结构原本被改动的节点值进行存储。不过相应的就需要改变下面程序里面的一些相关判断条件)。

    步骤五: 返回计数器count。

    Tips

    注意!输入的是一个 二维字符数组 而不是一个 普通二维数组
    用上之前提到的

    c-'0'
    不香吗?

    if(grid[i][j]-'0');
    if(grid[i][j]=='1');

    当然和个人喜好有关系,我个人更喜欢第一种的方式。

    代码实现 & 运行结果

    typedef struct{
    int x, y;
    }Node;
    
    int numIslands(char** grid, int gridSize, int* gridColSize){
    /*特例判断*/
    if(!grid || !gridSize || !(*gridColSize)) return 0;
    
    int directions[4][2] = {{-1, 0}, {0, -1}, {1, 0}, {0, 1}};
    Node* queue = (Node *)malloc(sizeof(Node *) * gridSize * (*gridColSize));
    
    int count=0;
    
    for(int i=0; i<gridSize; i++){
    for(int j=0; j<*gridColSize; j++){
    int front=0;
    int tail=0;
    
    if(grid[i][j]-'0'){
    count++;
    grid[i][j] = '0';
    queue[tail].x = i;
    queue[tail++].y = j;
    
    while(front<tail){
    int x=queue[front].x, y=queue[front].y;
    front++;
    
    for(int i=0; i<4; i++){
    int x_next=x+directions[i][0], y_next=y+directions[i][1];
    if(x_next<0 || x_next>gridSize-1 || y_next<0 || y_next>(*gridColSize)-1 || !(grid[x_next][y_next]-'0')) continue;
    grid[x_next][y_next] = '0';
    queue[tail].x = x_next;
    queue[tail++].y = y_next;
    }
    }
    }
    }
    }
    return count;
    }

    其他解法

    此题自然是有别的解法,比如DFS等等。我们将在学习到相应解法的时候,再使用相应解法解题。学一种方法,用一种方法。

    2. 打开转盘锁

    你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有10个数字:

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

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

    列表

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

    字符串

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

    分析

    题目里提到“四个圆形拨轮的转盘锁”,所以我们类比上文解决的 岛屿数量 题,就相当于,岛屿数量就像一个只有两个圆形拨轮的转盘锁。

    四个圆形转轮,每个转轮十个状态,那么就是10^4=10000个状态。而我们的初始位置为“0000”, 所以我们需要对“0000”进行特例分析。

    以“0000”作为最开始入列的状态。第一个循环,找“0000”的邻居,如果满足条件(不是死亡邻居),则入列,邻居寻找完毕之后,队列的第一个元素出列。第二个循环,从“0000”的非死亡邻居中,按照入队顺序,依次寻找他们的非死亡邻居······

    我们给一个四维数组

    int state[10][10][10][10] = {0};
    来记录每一个邻居的状态,初始化将每个邻居的状态都记为非死亡。之后遍历“死亡组合”
    char ** deadends
    ,将所有处于死亡状态的元素都赋值为1(之后的BFS中,我们只需要避开这些死亡状态的元素就可以啦)
    for(int i=0; i<deadendsSize; i++) state[deadends[i][0]-'0'][deadends[i][1]-'0'][deadends[i][2]-'0'][deadends[i][3]-'0'] = 1;
    .

    具体主要实现步骤

    步骤一:

    1. 特例分析一,如果没有不存在死亡状态呢?
      if(!deadends || !deadendsSize) return target[0]-'0'+target[1]-'0'+target[2]-'0'+target[3]-'0';
    2. 如果本身“0000”这个状态就是死亡状态呢?
      if(state[0][0][0][0]) return -1;

    步骤二:

    • 建立一个数组存储所有可能的方向
      int directions[8][4] = {{1,0,0,0},{-1,0,0,0},{0,1,0,0},{0,-1,0,0},{0,0,1,0},{0,0,-1,0},{0,0,0,1},{0,0,0,-1}};
    • 标记所有“死亡状态”的元素

    步骤三:

    1. 新建一个队列,并初始化
    2. 初识结点入队列“0000”

    步骤四:BFS

    • 如果遇到了目标结点,就返回step计数器;
    • 遍历所有非死亡结点都没有找到目标节点,则返回-1,代表搜索失败

    Tips

    在处理数组越界问题时,我们会遇到下面这种两种情况:

    1. 如果某个转轮的状态为0,将其反转,则应该变为9. 而我们的方向二维数组里面的体现是0-1=-1;
    2. 如果某个转轮的状态为9,将其转动,则应该变为0. 而我们的方向二维数组里面的体现是9+1=10;

    那么我们如何处理这个问题呢?

    我使用了取余的方法。如果是情况2,将9+1对10取余,不就是0了么?

    int a_next=(queue[front].a+directions[j][0])%10;

    那如果是情况1呢? -1对10取余还是-1呀?(我曾以为是9,但是程序执行的时候报了错,说四维数组的索引-1越界了)

    于是我就在原代码上进行修改,仍旧只有一行代买,加了三个字母:

    int a_next=(queue[front].a+directions[j][0]+10)%10;

    自我感觉此法很巧妙。如果正在阅读此篇文章的你有什么好的方法,欢迎在评论里告诉我呀~( ̄y▽, ̄)╭

    代码实现以及执行结果

    typedef struct{
    int a, b, c, d;
    }Node;
    
    int openLock(char ** deadends, int deadendsSize, char * target){
    
    if(!deadends || !deadendsSize) return target[0]-'0'+target[1]-'0'+target[2]-'0'+target[3]-'0';
    
    int directions[8][4] = {{1,0,0,0},{-1,0,0,0},{0,1,0,0},{0,-1,0,0},{0,0,1,0},{0,0,-1,0},{0,0,0,1},{0,0,0,-1}};
    int state[10][10][10][10] = {0};
    
    for(int i=0; i<deadendsSize; i++) state[deadends[i][0]-'0'][deadends[i][1]-'0'][deadends[i][2]-'0'][deadends[i][3]-'0'] = 1;
    
    if(state[0][0][0][0]) return -1;
    
    Node * queue = (Node *)malloc(sizeof(Node) * 10000);
    int front=0, tail=0, step=0;
    
    // 初始点入队列
    queue[tail].a = 0;
    queue[tail].b = 0;
    queue[tail].c = 0;
    queue[tail++].d = 0;
    state[0][0][0][0] = 1;
    
    while(front<tail){
    step++;
    int size = tail - front;
    for(int i=0; i<size; i++){
    for(int j=0; j<8; j++){
    int a_next=(queue[front].a+directions[j][0]+10)%10;
    int b_next=(queue[front].b+directions[j][1]+10)%10;
    int c_next=(queue[front].c+directions[j][2]+10)%10;
    int d_next=(queue[front].d+directions[j][3]+10)%10;
    
    if(state[a_next][b_next][c_next][d_next]) continue; //a_next>-1 || b_next>-1 || c_next>-1 || d_next>-1 ||
    
    state[a_next][b_next][c_next][d_next] = 1;
    queue[tail].a = a_next;
    queue[tail].b = b_next;
    queue[tail].c = c_next;
    queue[tail].d = d_next;
    
    if(queue[tail].a==target[0]-'0' && queue[tail].b==target[1]-'0' && queue[tail].c==target[2]-'0' && queue[tail].d==target[3]-'0') return step;
    
    tail++;
    }
    front++;
    }
    }
    return -1;
    }

    3. 完全平方数

    给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, …)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。

    输入: n = 12
    输出: 3
    解释: 12 = 4 + 4 + 4.

    输入: n = 13
    输出: 2
    解释: 13 = 4 + 9.

    分析

    看完题目一脸懵,懵逼树下你我懵。

    懵了一会儿,我想到了用BFS搜索n叉树的方式。

    举例,比如说,如果输入的数为

    17
    。我们建立如下树

    至于是否入列的规则,为下图(本来是想放在树的图里的,但是不知道放在哪里合适,于是就单独截图放出来了(●’◡’●)):

    可能

    17
    这个例子太简单,层数不那么多,可能没能让你很好地理解它的原理,接下来将把
    15
    作为输入例子。示意图如下:

    遍历顺序为从左到右遍历,所以后面这个输入例子为

    15
    的情况下,第三层的第一个就直接结束程序了。

    Tips

    因为要频繁寻找最大的平方数,以及判断一个数是否为平方数,所以我单独给这两个过程写了对应的函数。

    int FindMaxSqrt(int n){
    for(int i=0; ;i++) if(pow(i,2)>n) return i-1;
    }
    bool WhetherisNumSquare(int n){
    if(pow(floor(sqrt(n)),2)==n) return true;
    else return false;
    }

    代码实现以及执行结果

    int FindMaxSqrt(int n){
    for(int i=0; ;i++) if(pow(i,2)>n) return i-1;
    }
    bool WhetherisNumSquare(int n){
    if(pow(floor(sqrt(n)),2)==n) return true;
    else return false;
    }
    int numSquares(int n){
    if(!n) return 0;
    if(WhetherisNumSquare(n)) return 1;
    
    int* queue = (int *)malloc(sizeof(int) * 100000);
    int front=0, tail=0, step=0;
    queue[tail++] = n;
    
    while(front<tail){
    step++;
    int size = tail - front;
    for(int i=0; i<size; i++){
    int num = queue[front++];
    int temp = FindMaxSqrt(num);
    while(temp>0){
    if(WhetherisNumSquare(num-pow(temp,2))) return step+1;
    else queue[tail++] = num-pow(temp,2);
    temp--;
    }
    }
    }
    return -1;
    }
    

    思考

    因为不知道链表具体要分配多少的空间,所以我直接给了一个蛮大的空间

    sizeof(int) * 100000
    ,用空间换时间,也还是值得的,毕竟执行时间的数据还是很客观的。如果你有对于不同的n确定相应分配空间的大小,可以在文末评论区留言告诉我,非常感谢🙏。

    总解

    上文中的三个程序,都是有多种多样的方法解决。但是为了和本文的主题对应,我都使用了BFS来解决问题。

    可以发现,学会一套模板,三道题都可以解决。

    第一道题,我照着模板一字一字的敲;第二道题,我只有在不确定的时候看模板;第三道题,没有看模板,我也知道如何用代码实现BFS。

    这就是做题的力量💪。

    都看到这里了,确定不点个赞再走?(╯‵□′)╯︵┻━┻

    • 点赞
    • 收藏
    • 分享
    • 文章举报
    Whywait_1 发布了44 篇原创文章 · 获赞 26 · 访问量 2044 私信 关注
    内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: 
    相关文章推荐