代码随想录算法训练营打卡Day30 | LeetCode332 重新安排行程、LeetCode51 N皇后、LeetCode37 解数独

摘要

  • 今天以尝试较难的题目为主,为日后复习做准备。
  • DFS可以看做一个“拆边”的过程,用回溯法来搜索合适的“拆边”方案。
  • N皇后和数独问题都可以通过模拟过程,抽象出构造答案的树形结构,用回溯法搜索答案。
  • 数独问题的有一个剪枝条件隐含在枚举[1-9]仍不能填入当前位置,说明之前填入的数字也要调整,应该直接返回到上一层进行调整,而不能跳过当前位置。

LeetCode332 重新安排行程

332. 重新安排行程 - 力扣(Leetcode)

  • 这道题目也是图论DFS的经典题目,虽然目前复习进度还没有到图的部分,但这道题目套用回溯法的模板也还算能写。
  • 直观地去理解DFS,就是一个“拆边”的过程,把所有边拆完了(在本题中是将所有边拆完,即“用完所有的机票”),就到达回溯法树形结构的叶节点了,此时就要判断是否要收集答案。
  • LeetCode的示例2为例,先模拟一下“安排行程”的过程
  • 题目还要求按字典序返回最小的行程组合,为了满足这一要求,每次选取下一个目的地时,应该优先选取字典序最小的目的地,这蕴含了“贪心”的思想。
  • 拆边的过程如下图,当前所在节点用青蓝色表示,题目要求从“JFK”开始
  • 取以上每一步的当前节点,即得到答案:["JFK","ATL","JFK","SFO","ATL","SFO"]

  • 这道题目如果不使用STL的话,要自己实现从地点名到数组下标的映射,以便保存机票数组对应的图的邻接矩阵。我目前阶段的复习以逻辑和概念为主,这次先不重复造轮子了,之后补一个不用STL的map的题解。

  • 既然要DFS,首先肯定要在代码中把图表示出来,图的实现有两种,一是邻接表,二是邻接矩阵。使用STL的map能很方便的实现邻接矩阵,通过哈希映射,就不需要我们自己手动将地点名映射到数组下标来构建邻接矩阵了。这道题目的图是可以有很多重边的,所以邻接矩阵的值是可以大于1的,用来记录到底从起点到终点有几条边。

unordered_map<string, map<string, int>> ticketsMap;
for (auto& iter : tickets) {
    ticketsMap[iter[0]][iter[1]]++;
}
  • 以上代码的含义为:unordered_map<起点机场,map<终点机场,有几张票>>

  • 另外要注意的是,map会自动按key值对存入的二元组进行排序,这里key值为终点机场string,所以用map保存时自动按字典序升序排列了终点机场。这样就能保证先尝试的下一个机场的字典序是较小的,从而我们第一次得到的一条合法路径的字典序应该是最小的。

  • 其实,处理好如何保存图的邻接矩阵,并利用map解决了字典序的问题,这道题目用回溯法的模板就比较好解决了,至于再往后的优化,还是之后复习到图的部分后再回来继续学习吧。

  • 另外要注意的是,对于图的问题,叶节点的深度是不确定的,所以没有明确的递归终止条件。但有收集答案的条件,即第一次得到合法路径时直接返回当前路径到主函数。

完整的题解代码如下

class Solution {
public:
    void backtracking(unordered_map<string, map<string, int>>& ticketsMap,
                    vector<string>& res, int ticketsNum, bool& reach)
    {
        if (res.size() == ticketsNum + 1) {
            reach = true;
            return;
        }
        for (auto& iter :  ticketsMap[res.back()]) {
            if (iter.second) {
                res.push_back(iter.first);
                iter.second--;
                backtracking(ticketsMap, res, ticketsNum, reach);
                if (reach) return;
                iter.second++;
                res.pop_back();
            }
        }
    }
    vector<string> findItinerary(vector<vector<string>>& tickets) {
        unordered_map<string, map<string, int>> ticketsMap;
        for (auto& iter : tickets) {
            ticketsMap[iter[0]][iter[1]]++;
        }
        vector<string> res;
        res.push_back("JFK");
        bool reach = false;
        backtracking(ticketsMap, res, tickets.size(), reach);
        return res;
    }
};

LeetCode51 N皇后

51. N 皇后 - 力扣(Leetcode)

  • 在放下一枚皇后之前,应该判断当前位置是否能让皇后彼此之间不互相攻击。即判断在同一行、同一列,同一条斜线(注意有两条,一条平行于主对角线,一条垂直于主对角线)上不能有两枚皇后。不难直接写出以下函数:(cur表示当前棋盘的状态)
bool isNotValid(vector<string>& cur, int row, int col, int n) {
    // 同一行
    for (int i = 0; i < n; i++) {
        if (cur[row][i] == 'Q' && i != col) return true;
    }
    // 同一列
    for (int i = 0; i < n; i++) {
        if (cur[i][col] == 'Q' && i != row) return true;
    }
    
    // 同一斜线(平行于主对角线的斜线)
    for (int i = 1; row - i >= 0 && col - i >= 0; i++) {
        if (cur[row - i][col - i] == 'Q') return true;
    }
    for (int i = 1; row + i < n && col + i < n; i++) {
        if (cur[row + i][col + i] == 'Q') return true;
    }
    
    // 同一斜线(垂直于主对角线的斜线)
    for (int i = 1; row - i >= 0 && col + i < n; i++) {
        if (cur[row - i][col + i] == 'Q') return true;
    }
    for (int i = 1; row + i < n && col - i >= 0; i++) {
        if (cur[row + i][col - i] == 'Q') return true;
    }
    return false;
}
  • 当然,这是最直接也是最简陋的判断方式。简洁的判断方式有很多,之后再继续复习。
  • 有了以上函数,就可以在回溯法中对不符合要求的摆放方式进行剪枝,最终收集到符合要求的摆放方式。
    • 逐行放置皇后,判断当前的摆放方案是否合法
    • 不合法则直接剪枝,无需再继续尝试
    • 递归的终止条件为最后一行摆放完成。
class Solution {
public:
    bool isNotValid(vector<string>& cur, int row, int col, int n) {
        for (int i = 0; i < n; i++) {
            if (cur[i][col] == 'Q' && i != row) return true;
        }
        for (int i = 1; row - i >= 0 && col - i >= 0; i++) {
            if (cur[row - i][col - i] == 'Q') return true;
        }
        for (int i = 1; row + i < n && col + i < n; i++) {
            if (cur[row + i][col + i] == 'Q') return true;
        }
        for (int i = 1; row - i >= 0 && col + i < n; i++) {
            if (cur[row - i][col + i] == 'Q') return true;
        }
        for (int i = 1; row + i < n && col - i >= 0; i++) {
            if (cur[row + i][col - i] == 'Q') return true;
        }
        return false;
    }
    void backtracking(vector<vector<string>>& res, int n,
                    int row, vector<string>& cur)
    {
        if (row >= n) {
            res.push_back(cur);
            return;
        }
        for (int i = 0; i < n; i++) {
            if (isNotValid(cur, row, i, n)) continue;
            cur[row][i] = 'Q';
            backtracking(res, n, row + 1, cur);
            cur[row][i] = '.';
        }
    }
    vector<vector<string>> solveNQueens(int n) {
        vector<vector<string>> res;
        vector<string> cur(n, string(n, '.'));
        backtracking(res, n, 0, cur);
        return res; 
    }
};
  • N皇后问题还有很多可以深入讨论的地方,以上这种模拟摆放的实现方式虽然直观,但不是效率最高的方法。直观有时候意味着对问题的抽象程度还不够,所以效率不够高也是自然的。

LeetCode37 解数独

37. 解数独 - 力扣(Leetcode)

  • 回溯法基本都是在模拟构造答案的过程,将答案的构造过程抽象为树形结构进行搜索:。填写数独,首先就要判断当前位置能填写什么数字,不难根据数独的规则直接写出如下剪枝函数
bool isValid(int row, int col, char k, vector<vector<char>>& board) {
    // 同一行或同一列
    for (int i = 0; i < board.size(); i++) {
        if (i != col && board[row][i] == k) return false;
        if (i != row && board[i][col] == k) return false; 
    }
    // 同一个九宫格
    int Row = row / 3 * 3;
    int Col = col / 3 * 3;
    for (int i = Row; i < Row + 3; i++) {
        for (int j = Col; j < Col + 3; j++)
            if (i != row && j != col && board[i][j] == k) return false;
    }
    return true;
}
  • 递归的终止条件:先统计输入的矩阵已经有多少个数字,保存在count中,当count等于81时,说明已经数独的矩阵已经被填满,可以直接返回结果了。

完整的题解代码如下,用row控制逐行填写矩阵,减少不必要的遍历

class Solution {
public:
    bool isValid(int row, int col, char k, vector<vector<char>>& board) {
        // 同一行或同一列
        for (int i = 0; i < board.size(); i++) {
            if (i != col && board[row][i] == k) return false;
            if (i != row && board[i][col] == k) return false; 
        }
        // 同一个九宫格
        int Row = row / 3 * 3;
        int Col = col / 3 * 3;
        for (int i = Row; i < Row + 3; i++) {
            for (int j = Col; j < Col + 3; j++)
                if (i != row && j != col && board[i][j] == k) return false;
        }
        return true;
    }
    void backtracking(vector<vector<char>>& board, bool& solved,
                    int& count, int row)
    {
        if (count == board.size() * board.size()) {
            solved = true;
            return;
        }
        for (int i = row; i < board.size(); i++) {
            for (int j = 0; j < board[0].size(); j++) {
                if (board[i][j] != '.') continue;

                for (char k = '1'; k <= '9'; k++) {
                    if (isValid(i, j, k, board)) {
                        count++;
                        board[i][j] = k;
                        // 用三目表达式控制换行
                        backtracking(board, solved, count, j < 8 ? row : row + 1);
                        if (solved) return;
                        board[i][j] = '.';
                        count--;
                    }
                }
                // 如果程序执行到这里
                // 表示这个位置不能填入任何数,前面填入的数字也需要调整,直接返回
                return;
                // 不能跳过当前位置再去填下一个位置
            }
        }
    }
    void solveSudoku(vector<vector<char>>& board) {
        bool solved = false;
        int count = 0;
        // 统计固定好的数字个数
        for (int i = 0; i < board.size(); i++) {
            for (int j = 0; j < board[0].size(); j++) {
                if (board[i][j] != '.') count++;
            }
        }
        backtracking(board, solved, count, 0);
    }
};
  • 填数独问题中,有一个剪枝方式和之前的题目都不同,我在这里卡了一会
for (int i = row; i < board.size(); i++) {
    for (int j = 0; j < board[0].size(); j++) {
        if (board[i][j] != '.') continue;

        for (char k = '1'; k <= '9'; k++) {
            if (isValid(i, j, k, board)) {
                count++;
                board[i][j] = k;
                // 用三目表达式控制换行
                backtracking(board, solved, count, j < 8 ? row : row + 1);
                if (solved) return;
                board[i][j] = '.';
                count--;
            }
        }
        // 如果程序执行到这里
        // 表示这个位置不能填入任何数,前面填入的数字也需要调整,直接返回
        return;/**/
        // 不能跳过当前位置再去填下一个位置
    }
}
  • 后跟/**/的那句return是很关键的剪枝,
    • 这个剪枝不是在尝试填入数字之前进行的,
    • 而是尝试完所有数字后仍然不能进入下一层递归才进行的剪枝
    • 与在尝试枚举当前位置的元素之前进行的剪枝不同,这种剪枝是在尝试枚举当前位置的元素之后进行的。
    • 因为没有显式的if来描述剪枝条件,剪枝条件暗含在尝试了所有元素仍不能填入当前位置,只有短短的一句return,非常容易忘记写,导致代码逻辑不对。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,099评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,473评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,229评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,570评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,427评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,335评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,737评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,392评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,693评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,730评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,512评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,349评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,750评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,017评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,290评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,706评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,904评论 2 335

推荐阅读更多精彩内容