全排列(之前写的 不要看 是坨shi)

84 阅读7分钟

2024-12-24 写的。今天是2025-3-11. 看得出来因为刷题所以自己进步很大了。今天回头看 觉得写的也太幼稚了。直接看灵神的视频然后刷题就好。

做了leetcode 有一阵子了,Bloomberg的面试正好也是考的全排列。对于全排列的感受更深了,所以又写一篇记录感受。全排列相对来说是很简单的一种题型。它的本质是把所有可能的情况列出来

Don‘t Freak out. 用代码 把所有情况写一遍。

template:

vector<vector<int>> ans;

vector<vector<int>> permute(vector<int>& nums) {
        dfs(nums, {});
        return ans;
}

void dfs(...) {
    if(到达结束条件){
        res.push_back(已选列表);
        return;
    }

    for(选择 : 选择列表){
    	排除无效的选择(剪枝)
        做选择;
        dfs(路径,选择列表);
        处理撤回
        撤销处理结果        
    }
}

就很简单 把所有可能性排列出来就可以了。常常和string一起考 需要牢记substr等一些string处理方法。

同时,注意细节,比如 暂停后,记得return。比如 string 需要记住 不要超过它本身的长度。

其他排列例题: blog.lichangao.com/daily_pract…

这里面所有的关于回溯的题,在2024/12/24 之前已经全部做完了。mid系列只需要记住template 直接套就行。

例题:46. Permutations

class Solution {
    vector<vector<int>> ans;
    unordered_set<int> dict;
public:
    vector<vector<int>> permute(vector<int>& nums) {
        dfs(nums, {});
        return ans;
    }
    void dfs(vector<int> nums, vector<int> temp){
        if(temp.size() == nums.size()) ans.push_back(temp);

        for(int i = 0; i < nums.size(); i++){
            if(dict.find(nums[i]) == dict.end()){
                dict.insert(nums[i]);
                temp.push_back(nums[i]);
                dfs(nums, temp);          
                temp.pop_back();
                dict.erase(nums[i]);  
            }    
        }
    }
};

经验:

  1. 有没有重复的数字:用unordered_set<int>去解决。并且只要在ans那里写unordered_set<int>/<char>即可,因为所有的排列都是depth first search,先从最下面走,走不动再稍微往上走,不用担心会混在一起。比如上题。比如37. Sudoku Solver (37老是遗忘条件)(有空多做做)

  2. 在写dfs的时候,我们会使用void或者bool。我让chatgpt写了一个什么时候用什么方法的方法论,觉得很有帮助,贴到下面了。在用 DFS(Depth First Search)做搜索或者回溯(backtracking) 时,是否需要返回 bool 很大程度上取决于你想实现的逻辑以及是否需要剪枝。我们通常会看到两种常见写法:

  3. bool dfs(...)

  4. void dfs(...)

这两种写法本质上没有绝对的“好”或“坏”,它们更像是从需求角度出发,选择合适的函数返回值和函数功能。下面我们来探讨为什么在有些场景下需要 bool 来做“剪枝”,而在另外一些场景下只需要 void


1. 为什么使用 bool dfs(...)

1.1 用于剪枝或提早结束搜索

  • 场景: 比如搜索一个迷宫,想知道是否能走到终点。一旦找到一条可行路径,就没必要继续搜索其他路径了。

  • 实现思路: DFS 函数在搜索到可行解时返回 true,告诉上层调用者“已经找到解了”。上层调用者检查返回值,如果是 true,就可以停止其它分支的搜索(即“剪枝”)。

  • 代码示例 (示意):

    bool dfs(int x, int y) {
        if (到达目标) {
            return true;   // 找到可行解,返回 true
        }
        // 尝试扩展
        for (每个可能的方向) {
            if (该方向可行) {
                if (dfs(新x, 新y)) {
                    return true;  // 如果子问题返回 true,直接传递 true
                }
            }
        }
        return false;  // 所有方向都走不通,就返回 false
    }
    

    这种写法使得一旦找到可行解,就能快速停止剩余搜索,达到剪枝的效果。

1.2 用于判断当前搜索分支是否成功

  • 场景: 例如做回溯寻找一个满足某种条件的组合或排列,一旦这个组合找到,就不必再继续下去了。
  • 思路: 你可以利用 dfs 的返回值标记某个分支有没有成功。一旦成功,外层就停止再向下深搜。
  • 好处: 通过返回 bool,不用再额外定义全局变量或类成员变量来记录是否已经找到解,显得逻辑更加“内聚”。

2. 为什么使用 void dfs(...)

2.1 不需要剪枝或不需要提前结束

  • 场景: 你可能需要遍历搜索树上的所有结果。例如要输出所有满足条件的解、不需要只找到任意一个就够了。

  • 实现思路: 既然要全部遍历,就不需要一旦找到满足条件就立刻返回。因此没必要通过 bool 中途返回。

  • 代码示例 (示意):

    void dfs(int depth, vector<int>& path) {
        if (depth == n) {
            // 输出/记录当前 path
            return;
        }
        // 枚举所有可能选项
        for (auto choice : someChoices) {
            path.push_back(choice);
            dfs(depth + 1, path);
            path.pop_back();
        }
    }
    

    这里搜索的目的是列举所有可行解,也就不再需要通过返回值剪枝或中断搜索。

2.2 逻辑简单,只做遍历标记

  • 场景: 如果只是想对一幅图/树做访问标记,或者统计某些全局信息(如连通分量、访问次数等),根本不涉及到“找到就停”这样的逻辑。

  • 实现思路: 这种场景下,用 void 来实现 DFS 更加自然,函数体内只做标记、访问、累加等操作,不需要返回任何结果。

  • 示例:

    vector<bool> visited;
    
    void dfs(int u) {
        visited[u] = true;
        for (auto v : adj[u]) {
            if (!visited[v]) {
                dfs(v);
            }
        }
    }
    

    这种写法只做简单的遍历标记,没有任何要“提早结束搜索”的需求。


3. 如何选择?

  1. 如果你的搜索需求是“一旦找到结果就可以停止” ,或者“要根据子函数的返回值来确定是否剪枝”——就考虑返回 bool,用返回值做早停控制。
  2. **如果你需要“遍历所有可能”**或者“对搜索过程中所有信息都要处理”——可以使用 void,因为你的搜索不会在中途结束,更不会需要用返回值传递状态。

3.1 可能的“混合”情况

有时,我们既想得到某些搜索分支的结果,又想把部分信息带回给上层,这时可能还会用其他方式(如全局变量、传引用、返回复杂结构体等等)来辅助。选择哪种方案主要看哪种方式可以让你的代码结构更简洁、可读性更好。


总结

  • bool dfs(...) 主要服务于“找到就返回”的场景,可以快速剪枝、停止搜索。
  • void dfs(...) 则多用于需要全部遍历、或者需要用 DFS 做标记、统计等操作而不需要提前结束的情况。

因此,“如果 DFS 情况复杂且存在需要剪枝的逻辑,就用 bool 来返回;如果 DFS 的逻辑只是普通遍历或是无须剪枝,就用 void”——这个理解是非常常见、也很符合实际情况的。

记住:具体怎么写,还是要看最终的需求,把需求和代码的实现逻辑对应起来,才能做出合适的选择。

对于leetcode hard的俩道题: 37 和Word Break II 因为我们可以提早结束,37其实只有一种解,所以我们用bool,word breakII 我们需要所有情况而非一种情况,所以我们用void dfs

  1. 去重

有时候重复情况:sort(nums.begin(), nums.end()) + if(i != idx && candidates[i] == candidates[i-1]) continue;就可以去重。感觉不错。比如下面这道经典题:Combination Sum II

class Solution {
    vector<vector<int>> ans;
public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());
        dfs(candidates, target, {}, 0);
        return ans;
    }
    void dfs(vector<int>& candidates, int target, vector<int> temp, int idx){
        if(target == 0){
            ans.push_back(temp);
            return;
        }
        if(target < 0){
            return;
        }
        for(int i = idx; i < candidates.size(); i++){
            if(i != idx && candidates[i] == candidates[i-1]) continue;
            temp.push_back(candidates[i]);
            dfs(candidates, target - candidates[i], temp, i+1);
            temp.pop_back();
        }
    }
};

但对于491. Non-decreasing Subsequences 去重是不能用sort(nums.begin(), nums.end()) + if(i != idx && candidates[i] == candidates[i-1]) continue;而是需要每层增加一个 unordered_set<int> nums 每层单独去重。所以每道题都很有意思,是很有趣的智力测试。最基础的去重还是 unordered_set 进化为sorted。很有趣。

这道题的解自己可以点进去看。

  1. 经常犯的错误

      for(int i = idx; i < candidates.size(); i++){
             if(i != idx && candidates[i] == candidates[i-1]) continue;
             temp.push_back(candidates[i]);
             dfs(candidates, target - candidates[i], temp, i+1);
             temp.pop_back();
         }
    

    里面的i和idx常常乱写。该写i的地方写成idx