搜索与回溯

48 阅读5分钟

中等

剑指 Offer 12. 矩阵中的路径

本题主要考察DFS以及剪枝。

首先理解题目意图:给出一个字符矩阵和一个字符串,然后要求我们在字符矩阵中寻找是否存在某条路径,其权值就是给定的字符串。

解题思路:可以暴力所有矩阵节点,对于每个节点采用DFS,从其相邻的节点出发进行尝试,为了减少尝试次数,可以在每次尝试时,将当前节点的值设置为\0,从而注明:当前节点已经查找过了(题目要求只能使用一次),也就是所谓的剪枝操作,当然这里的剪枝还有:下标越界时,直接终止。

参考代码(c++):

class Solution {
private:
    int rows, cols; // 记录矩阵行列数
    // 这里需要理清楚dfs需要的参数
    // i-行, j-列, k-当前尝试的word字符下标
    bool dfs(vector<vector<char>>& board, string word, int i, int j, int k) {
        // 剪枝,直接终止
        if (i < 0 || i >= rows || j < 0 || j >= cols || board[i][j] != word[k]) {
            return false;
        } else if (k == word.size() - 1) return true; // 这里已经通过了上面的判断,如果查找完毕表明存在该路径

        bool res;
        board[i][j] = '\0'; // 标注已查找过的节点
        res = dfs(board, word, i, j + 1, k + 1) || dfs(board, word, i, j - 1, k + 1) || dfs(board, word, i - 1, j, k + 1) || dfs(board, word, i + 1, j, k + 1); // 尝试相邻节点
        board[i][j] = word[k]; // 回溯恢复原状态

        return res;
    }




public:
    bool exist(vector<vector<char>>& board, string word) {
        rows = board.size();
        cols = board[0].size();

        for (int i = 0; i < rows; i ++) {
            for (int j = 0; j < cols; j ++) {
                bool res = dfs(board, word, i, j, 0); // 暴力尝试所有节点
                if (res) return true;
            }
        }

        return false;
    }
};

复杂度分析

时间复杂度:O(3^K * MN),最坏情况是要遍历所有节点:O(MN),而每次遍历某个节点需要试探四种情况,舍弃掉进入当前节点的方向(来的方向)的情况,每种情况递归尝试K次,即O(3^K)

空间复杂度:O(K),每次递归的深度不会超过K,递归结束会释放栈的空间,最坏的情况就是word长度为MN,即要递归整个矩阵。

面试题13. 机器人的运动范围

先来梳理题目意图:要求我们找出矩阵中符合规则的方格,那么规则又是什么呢?就是从左上角开始进行移动(尝试),每次只能垂直或者水平移动,并且进入的方格行和列下标的所有数字之和不能大于K。

解题思路:这道题既可以采用DFS,也可以采用BFS,DFS就是从一个点一直尝试到失败为止,然后原来返回上一个节点,再从这个节点出发进行深度尝试,如此循环;而BFS则是从一个点出发每次尝试临近的节点,然后再将所有的临近节点作为出发点进行尝试,如此往复。

有了大致的思考方向后,我们还需要仔细揣摩几个关键点:怎么求所有数字之和,每次都必须尝试四个方向(上下左右)的相邻节点吗?

  • 我们先看第二个问题:每次都必须尝试所有相邻节点?其实我们推敲下整个运动过程,可以发现是从左上角出发,然后不断的向右下方进行拓展,也就是说,其实每一步只需要考虑向右和向下就可以了,全部考虑存在许多的重复性问题。

  • 我们再来探讨第一个问题:如何求所有数字之和,对于这个问题,想必大家的想法都是实现一个方法,来进行求解,比如下面的代码:

int calculate(int x, int y) {
    int res = 0;
    while(x != 0) {
        res += x % 10;
        x /= 10;
    }
    
    while(y != 0) {
        res += y % 10;
        y /= 10;
    }
    return res;
}

整个方法的实现并没有难度,也是一种通解,不过我们下面要来讨论的是针对本题的特解,Si表示下标i的所有数字之和,Sj表示移动后下标的所有数字之和,机器人下一步移动要么向下,要么向右,如果向右,如果不仔细推敲可能认为Sj == Si + 1,这里要注意的是进位问题,比如19=>20,对于这种情况,Sj = Si - 8,因为十位加一,个位减-9,而另外的情况则是Sj = Si + 1,也就是说如果j % 10 = 0Sj = Si - 8,其他情况Sj = Si + 1

通过这种思考,我们就可以直接得出下一个所有数字之和了,而不用每次都通过函数求解。

参考代码(C++,BFS解法)

class Solution {
public:
    int movingCount(int m, int n, int k) {
        vector<vector<bool>> visited(m, vector<bool>(n, false)); // 辅助数组,记录已经访问过的节点,当成数组理解,访问过:true,未访问过:false
        queue<vector<int>> que; // 队列,记录所有临近节点
        que.push({0, 0, 0, 0}); // 初始化,左上角的节点
        int count = 0;

        while(!que.empty()) {
            vector<int> x = que.front();
            que.pop();

            int i = x[0], j = x[1], si = x[2], sj = x[3]; // i,j表示行和列,si表示下标i的所有数字之和,sj表示下标j的所有数字之和
            // 当前节点不符合条件,直接pass,这个节点不可达,那么它的相邻接节点无法通过它到达
            if (si + sj > k || i >= m || i < 0 || j >= n || j < 0 || visited[i][j] == true) continue;
            count++;
            visited[i][j] = true;
            
            // 入队右侧和下侧节点
            que.push({i, j + 1, si, (j + 1) % 10 == 0 ? (sj - 8) : (sj + 1)});
            que.push({i + 1, j, (i + 1) % 10 == 0 ? (si - 8) : (si + 1), sj});
        }

        return count;
    }
};

复杂度分析

时间复杂度:O(MN),需要尝试所有节点以确认是否可达

空间复杂度:O(MN),最坏的情况是所有节点都可达,都需要入队

剑指 Offer 34. 二叉树中和为某一值的路径

本题题意比较简单,在二叉树中找到从根结点到叶节点的所有符合规则的路径,规则:路径权值为预期值。

解题思路:看到题目的第一想法就是肯定要回溯,从根结点一直往下找,不合法就返回上一个节点,然后接着找,虽然知道思路是怎么回事,但是就是不知道如何下手coding,这里的关键点在于如何梳理回溯,这里我们来仔细思考回溯的细节,从root根结点出发,尝试它的左节点left1是否需要遍历,然后从left1出发,尝试它的左节点left2是否需要遍历,需要则接着上述步骤尝试,若不需要,则返回到left1,从left1出发,尝试它的右节点right1,然后从right1出发,尝试它的左/右节点.........,仔细观察就可以发现这个过程其实就是二叉树的先序遍历先根,后左右,想清楚了这一点, 代码就好写了(其实就是一个先序遍历+特定判断条件,仅此而已)

参考代码(C++)

class Solution {
public:
    vector<vector<int>> pathSum(TreeNode* root, int target) {
        // 先序遍历(回溯)
        recur(root, target);

        return res;
    }

private:
    // 记录单条路径
    vector<int> path;
    // 记录所有路径
    vector<vector<int>> res;

    void recur(TreeNode* node, int target) {
        // 根结点为空,或者叶子结点的子节点,终止
        if (node == nullptr) return ;
        // 在单条路径中记录目前访问的节点权值
        path.push_back(node->val);
           
        // 更新总权值
        target -= node->val;
        // 总权值为0,并且已经到叶子结点了,表示该路径符合要求
        if (target == 0 && node->left == nullptr && node->right == nullptr) {
            // 记录该条路径
            res.push_back(path);
        }

        // 尝试当前节点的左节点
        recur(node->left, target);
        // 尝试当前节点的右节点
        recur(node->right, target);
        // 将当前节点擦除,回溯
        path.pop_back();

    }
};

在上述代码中,可能不太好理解的就是回溯问题,这里需要我们从仔细思考回溯的每一步的视角跳出来,看待回溯的整个过程,也就是抽象整个问题,每一次往深层次遍历都会记录每个点node->val,然后在回溯的时候进行擦除path.pop_back(),以动态维护path

复杂度分析

时间复杂度:O(N),N为二叉树节点数,先序遍历需要遍历所有节点

空间复杂度:O(N),最坏的情况就是,二叉树退化成单链表,这时需要记录每一个节点

剑指 Offer 36. 二叉搜索树与双向链表

前置知识:二叉搜索树,又名:二叉排序树,二叉查找树。通过中序遍历(左中右),可以得到一个递增序列(左子树的节点值都小于根结点值,右子树的节点值都大于根结点值)。

解题思路:也就是说这道题是要求我们将已经排序的树状结构转换成双线链表结构,那么只需要按照中序遍历的模式逐步遍历,然后每次更新节点的前驱后后继。

参考代码(C++

class Solution {
public:
    Node* treeToDoublyList(Node* root) {
        if (root == nullptr) return nullptr;
        recur(root);
        // 最后需要完善头尾节点的连接,遍历结束时,pre指向尾结点
        head->left = pre;
        pre->right = head;
        return head;
    }

private:
    // 全局变量,pre-前驱节点,head-头节点
    Node* pre, *head;
    void recur(Node* cur) {
        // 叶子节点的节点直接跳过
        if (cur == nullptr) return ;
        // 中序遍历,先左
        recur(cur->left);
        
        // 中序遍历,再中
        // 当前节点,更新指针指向,前驱的后继=当前节点,当前节点的前驱=pre
        // 要注意的是,如果pre=null,表示初始状态,这时需要记录head的位置
        if (pre == nullptr) head = cur;
        else pre->right = cur;
        
        cur->left = pre;
        pre = cur; // 更新前驱节点
        
        // 中序遍历,最后右
        recur(cur->right);
    }
};

复杂度分析

时间复杂度:O(N),需要遍历所有节点,N为节点个数

空间复杂度:O(N),最坏的情况下,退化成单链表,由于递归的原因,需要缓存所有节点