回溯算法

272 阅读3分钟

一、回溯算法概念

image.png

回溯法的解题代码都比较类似

void func(const int arr[], int i, int len) {
    if (i == len) {
        for (int j = 0; j < len; j++) {
            cout << arr[j] << " ";
        }
        cout << endl;
    }
    else {
        func(arr, i + 1, len);
        func(arr, i + 1, len);
    }
}

int main() {
    int arr[] = { 1,2,3 };
    func(arr, 0, sizeof(arr)/sizeof(arr[0]));
    return 0;
}

image.png

二、子集树

void func(const int arr[], int i, int len, int flag[]) {
    if (i == len) {
        for (int j = 0; j < len; j++) {
            if (flag[j] == 1) {
                cout << arr[j] << " ";
            }
        }
        cout << endl;
    }
    else {
        flag[i] = 1;
        func(arr, i + 1, len, flag);
        flag[i] = 0;
        func(arr, i + 1, len, flag);
    }
}

int main() {
    int arr[3] = { 1,2,3 };
    int flag[3] = { 0 };
    func(arr, 0, sizeof(arr)/sizeof(arr[0]), flag);

    return 0;
}

image.png

三、整数选择问题一

给定一组整数,从里面挑出一组整数,使得选择的整数与未选择的整数之差最小

搜索了整个解空间树,尝试所有的子集(从根到叶子节点的所有路径代表一个子集),找出使选择的整数与未选择的整数之差最小子集

#include <iostream>
#include <stdlib.h>

using namespace std;

const int arr[] = { 12,6,7,11,16,3,9 };
const int len = sizeof(arr) / sizeof(arr[0]);
int choice[len] = { 0 };      // 辅助数组,记录节点走向左孩子还是右孩子,代表i节点是否被选择
int best_choice[len] = { 0 };  // 当前的最优解,解空间树搜索的过程中会更新
unsigned int min_diff = INT_MAX;  
int sum = 0;
int remain = 0;

void func(int i) {
    if (i == len) {
        // 到达解空间树叶子节点
        int diff = abs(sum - remain);
        if (diff < min_diff) {
            min_diff = diff;
            for (int j = 0; j < len; j++) {
                best_choice[j] = choice[j];
            }
        }
    }
    else {
        // 选择arr[i]
        // 准备进入左孩子
        choice[i] = 1;
        sum += arr[i];
        remain -= arr[i];
        func(i + 1);

        // 退出左孩子,向上回溯,准备进入右孩子

        // 不选择arr[i]
        choice[i] = 0;
        sum -= arr[i];
        remain += arr[i];
        func(i + 1);
        // 退出右孩子,向上回溯,准备回溯到父节点
    }
}


int main() {
    for (int v : arr) {
        remain += v;
    }
    func(0);
    cout << "min_diff = " << min_diff << endl;
    for (int i = 0; i < len; i++) {
        if (best_choice[i] == 1) {
            cout << arr[i] << " ";
        }
    }
    cout << endl;
    return 0;
}

复杂度分析:该算法遍历了解空间树所有的节点,算法的时间复杂度为O(2n+1)O(2^{n+1}),最好进行剪枝。n个元素生成的解空间树有n+1层,有O(2n)O(2^n)个叶子节点

三、整数选择问题二

给定2n整数,从里面挑出n个整数,使得选择的整数与未选择的整数之差最小

这题就是根据题目要求来给上一题添加剪枝操作:

  • 剪左孩子:递归进入左孩子,表示选择当前节点。当我目前选择的元素数量大于n了,我就没有必要再往左孩子走了
  • 剪右孩子:递归进入右孩子,表示不选择当前节点。当我目前选择的元素数量 + 我还能选择的元素个数 >= n的时候才有必要走右孩子
  • 剪所有孩子:当我目前选择的元素数量 + 我还能选择的元素个数 < n的时候,我就没必要继续尝试选择=或者不选了,直接回溯即可,放弃做选择
#include <iostream>
#include <stdlib.h>
#include <vector>

using namespace std;

const int arr[] = { 12,6,7,11,16,3,9,5 };
const int len = sizeof(arr) / sizeof(arr[0]);
vector<int> choice;      // 辅助数组,记录节点走向左孩子还是右孩子,代表i节点是否被选择
vector<int> best_choice;  // 当前的最优解,解空间树搜索的过程中会更新
unsigned int min_diff = INT_MAX;  
int sum = 0;
int remain = 0;
int cnt = 0;

void func(int i) {
    cnt++;
    if (i == len) {
        
        // 到达解空间树叶子节点
        int diff = abs(sum - remain);
        if (diff < min_diff) {
            min_diff = diff;    
            best_choice = choice;
        }
    }
    else {
        // 已经选择的元素 + 还能被选的元素 < 一半元素,就不用再尝试选还是不选了(我选了剩下所有元素都达不到要求,那我就不用尝试了,直接回溯)
        if (choice.size() + len - i < len / 2) {
            return;
        }
        // 还没有选够一半元素的时候,才会尝试左孩子
        if (choice.size() < len / 2) {
 
            choice.push_back(arr[i]);
            sum += arr[i];
            remain -= arr[i];
            func(i + 1);

            // 退出左孩子后才需要恢复原来的状态,如果都不进入左孩子,直接进入右孩子,就不需要做这个恢复状态的操作了
            choice.pop_back();
            sum -= arr[i];
            remain += arr[i];
        }
        func(i + 1);
        
        // 退出右孩子,向上回溯,准备回溯到父节点
    }
}


int main() {
    for (int v : arr) {
        remain += v;
    }
    func(0);
    cout << "min_diff = " << min_diff << endl;
    for (int v : best_choice) {
        cout << v << " ";
    }
    
    cout << endl;
    cout <<"cnt = "<< cnt << endl;
    return 0;
}

通过测试,不剪枝需要遍历511个节点,只剪左孩子需要遍历381个节点,只剪右孩子需要遍历465个节点,剪枝操作全加上需要遍历307个节点

四、挑数字01

问题描述:给出一组数字,从中挑选一部分,使得他们的和等于指定的值,存在则打印挑选的数字

分析:不管怎么说,这就是挑选一个子集,非常符合我们回溯算法的思想

剪左孩子:进入左孩子表示选择arr[i],如果当前所选择元素的和 + 当前元素arr[i] > target,那就没必要再走左孩子选择当前的元素arr[i]了

剪右孩子:进入右孩子表示不选择arr[i],如果当前的和 + 剩余可以选择元素的和 < target,也没必要再做选择了,这个时候直接回溯即可

#include <iostream>
#include <stdlib.h>
#include <vector>

using namespace std;

const int arr[] = { 4,8,12,16,7,9,3 };
const int len = sizeof(arr) / sizeof(arr[0]);
vector<int> choice;      // 记录选择的数字
int sum = 0;
int target = 17;
int remain = 0;
int cnt = 0;
 
void func(int i) {
    cnt++;
    if (i == len) {
        // 到达叶子节点
        if (sum == target) {
            for (int v : choice) {
                cout << v << " ";
            }
            cout << endl;
        }
    }
    else {

        // 开始处理当前节点
        remain -= arr[i];                 
        // 剪左树枝,当前选择元素的和 + 当前处理的元素 > target,再往左孩子走就不满足题目要求了
        if (sum + arr[i] <= target) {
            choice.push_back(arr[i]);
            sum += arr[i];                // 选择当前元素
            
            func(i + 1);

            sum -= arr[i];
            choice.pop_back();
        }
        
        // 剪右树枝,当前选择元素的和 + 未来所有可以选择的元素 < target,就没必要再走右孩子了(我现在的状态,选上后面所有的元素,都达不到target,就直接回溯吧) 
        // 注意,把左孩子处理完后,才走到这里,如果这里不再进入右孩子,相当于就直接回溯了
        if (sum + remain >= target) {
            func(i + 1);
        }

        remain += arr[i];               // 当前节点处理完成,往父节点回溯,所以需要加回来
    }
}


int main() {
    for (int v : arr) {
        remain += v;
    }
    func(0);
    cout << "cnt = " << cnt << endl;
    return 0;
}

注意区分未选择和未处理的区别:当前在解空间树第i层,那i+1往后的都是未处理的,i往前的分为已选择和未选择(都属于已处理)。只要是进入了func函数,就相当于走在树枝上,正在处理当前这个元素。走到下一个func函数处的时候,相当于进入到了下一个节点

五、挑数字02(更高效的解空间树)

问题描述:给出一组数字,从中挑选一部分,使得他们的和等于指定的值,存在则打印挑选的数字

用更高效的解空间树(子集树)搜索最优解

完整的新型解空间树如下: image.png

在遍历到的每个节点处都要计算当前的和是否是target,而不是等到叶子节点再做判断

  • 用for循环控制当前节点需要生成几个孩子节点
  • 若sum加上下一个元素值太大,大于target了,就不用生成这个孩子节点了,进行剪枝。开始试探是否生成下一个孩子节点
#include <iostream>
#include <stdlib.h>
#include <vector>

using namespace std;

const int arr[] = { 4,5,6,7,8,9,3 };
const int len = sizeof(arr) / sizeof(arr[0]);
vector<int> choice;      // 记录选择的数字
int sum = 0;
int target = 9;
int remain = 0;
int cnt = 0;

void func(int i) {
    cnt++;
    if (sum == target) {
        for (int v : choice) {
            cout << v << " ";
        }
        cout << endl;
    }
    // 如果sum已经是target了,就不用继续遍历孩子节点了,所以遍历孩子节点的for循环应该放在else里面
    else {
        // 还没有进入for循环的时候,表示当前节点还没有生成孩子节点,但是当前节点已经选择了
        // 在for循环里面判断,表示要不要使用j号元素生成孩子向下遍历
        for (int j = i; j < len; j++) {
            if (sum + arr[j] <= target) {
                choice.push_back(arr[j]);
                sum += arr[j];
                func(j + 1);
                choice.pop_back();
                sum -= arr[j];
            }
        }
    }
}


int main() {
    func(0);
    cout << "cnt = " << cnt << endl;
    return 0;
}

若元素可以重复选择,改成func(j)即可,意思是当前这个元素再次参与选择

六、01背包

回溯算法的代码都是相似的,自行处理一下相关变量,然后恢复状态即可

要注意的还是不能混淆已处理和已选择两个概念,只要是进入了func函数,就相当于走在树枝上,当前这个物品就正在被处理,树枝走完这个物品就被处理完了。

走完左树枝,表示选择当前物品,走完右树枝,表示不选择当前物品。

#include <iostream>
#include <stdlib.h>
#include <vector>

using namespace std;

const vector<int> w = {12,5,8,9,6};
const vector<int> v = {9,2,4,7,8};
int n = w.size();
const int c = 20;
int cur_v = 0;
int cur_c = c;
int remain_v = 0;

vector<int> choice;
vector<int> best_choice;  // 记录选取的物品
int best_v = 0;

int cnt = 0;

void func(int i) {
    cnt++;
    if (i == n) {
        // 到达叶子节点
        if (cur_v > best_v) {
            best_v = cur_v;         // 更新最优值
            best_choice = choice;   // 更新最优解
        }
    }
    else {
        // 处理区
        remain_v -= v[i];
        // 剪左子树
        if (cur_c - w[i] >= 0) {
            choice.push_back(w[i]);
            cur_c -= w[i];       // 当前背包容量
            cur_v += v[i];
            // 选择区  
            func(i + 1);

            choice.pop_back();
            cur_c += w[i];
            cur_v -= v[i];
        }
        // 剪右子树
        if (cur_v + remain_v > best_v) {
            func(i + 1);
        }
        remain_v += v[i];
    }
}


int main() {
    for (int tmp : v) {
        remain_v += tmp;
    }
    func(0);
    for (int w_ : best_choice) {
        cout << w_ << " ";
    }
    cout << endl;
    cout << best_v << endl;
    cout << "cnt = " << cnt << endl;
    return 0;
}

七、排列树

image.png 深入一层,则表示有一个元素的位置被确定好,节点的孩子少一个,一个叶子节点表示一种排列

#include <iostream>
#include <stdlib.h>
#include <vector>

using namespace std;

vector<int> arr = {1,2,3,4};
int n = arr.size();

int cnt = 0;

void func(int i) {
    
    if (i == n) {
        cnt++;
        for (int v : arr) {
            cout << v << " ";
        }
        cout << endl;
    }
    else {
        // 写递归的时候,只考虑一层,找好这一层的关系即可
        // i表示当前是第i层,根节点是第0层,叶子节点是第n层
        // 这个for循环是用来给当前的节点生成孩子节点的,同时j也表示i号元素可能放置的位置
        for (int j = i; j < n; j++) {
            swap(arr[i], arr[j]);      // 进入一个节点前要把元素交换好
            func(i + 1);
            swap(arr[i], arr[j]);      // 从节点回溯回来,需要把节点交换回来恢复父节点的状态,下次循环再交换其他的元素
        }
    }
}


int main() {
    func(0);
    cout << "cnt = " << cnt << endl;
    
}

八、八皇后问题

image.png

image.png

image.png

还是用排列树的方法解题,树的每一层就相当于处理一个皇后的位置。

如果当前层的某个节点(皇后的一种排列方式)已经和之前排列好的皇后冲突了,那就不用生成孩子节点了,因为孩子节点的排列方式是基于其父节点排列方式不冲突的前提下才生成的

for (int j = i; j < n; j++) {
    swap(chessboard[i], chessboard[j]);
    // 检查(i, chessboard[i])是否和前面放置好的i个皇后冲突
    // 由于chessboard[i]每个循环都在变,所以每次循环检查的并不是同一个位置i
    if (!is_collide(i)) {
        func(i + 1);
    }
    swap(chessboard[i], chessboard[j]);
}
  • i表示当前是第i层,根节点是第0层,每深入一层,就放好一个皇后
  • 父亲和孩子的关系就是,孩子比父亲多放好一个皇后
  • func(i)表示处理第i号皇后的位置,而此时0 ~ i-1号皇后已经放好了且不冲突
  • 叶子节点是第n层,确实第n-1层的时候,皇后就已经放好了,第n层只是打印
#include <iostream>
#include <stdlib.h>
#include <vector>

using namespace std;

vector<int> chessboard = {1,2,3,4,5,6,7,8};
int n = chessboard.size();

int cnt = 0;

// pos表示当前皇后排列的位置
// 检验当前皇后的位置是否和前面的皇后冲突
bool is_collide(int pos) {
    for (int i = 0; i < pos; i++) {
        if (chessboard[i] == chessboard[pos] || abs(i - pos) == abs(chessboard[i] - chessboard[pos])) {
            return true;
        }
    }
    return false;
}

void func(int i) {
    if (i == n) {
        cnt++;
        for (int v : chessboard) {
            cout << v << " ";
        }
        cout << endl;
    }
    else {
        for (int j = i; j < n; j++) {
            swap(chessboard[i], chessboard[j]);
            if (!is_collide(i)) {
                func(i + 1);
            }
            swap(chessboard[i], chessboard[j]);
        }
    }
}


int main() {
    func(0);
    cout << "cnt = " << cnt << endl;
    
}

九、穷举法解决全排列

image.png

vector<int> arr = { 1,2,3 };
const int n = arr.size();
int cnt = 0;
bool state[3] = { false };
vector<int> nums;     // 用一个数组记录当前选择的元素,选了就push_back,回溯的时候pop_back

void func(int i) {
    if (i == n) {
        cnt++;
        for (int v : nums) {
                cout << v << " ";
        }
        cout << endl;
    }
    else {
        // j用于试探孩子是否被选
        for (int j = 0; j < n; j++) {
            if (!state[j]) {
                // 当要深入孩子节点的时候,如果元素没被选择,才会选择该元素生成孩子
                state[j] = true;
                nums.push_back(arr[j]);
                func(i + 1);       // j表示可选元素的起始下标,i表示层数
                state[j] = false;
                nums.pop_back();
            }
        }
    }
}

int main() {
    func(0);
    cout << cnt;
    return 0;
}

十、穷举法解决不可重复全排列

image.png

image.png

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> choice;

    void func(int i, int n, vector<int>& nums, vector<bool>& used) {
        if (i == n) {
            ans.push_back(choice);
        }
        else {
            for (int j = 0; j < n; j++) {
                // 如果当前元素和前面元素重复,且前面的元素已经用于生成孩子节点了,那跳过重复节点
                if (j > 0 && used[j-1] && nums[j] == nums[j - 1]) {
                    continue;
                }

                if (!used[j]) {
                    // 只有生成孩子节点的时候,才会修改这个元素的状态
                    used[j] = true;
                    choice.push_back(nums[j]);
                    // 只要是没有被选择的元素都会被用来生成孩子节点
                    func(i + 1, n, nums, used);
                    choice.pop_back();
                    used[j] = false;
                }


            }
        }
    }

    vector<vector<int>> permuteUnique(vector<int>& nums) {
        vector<bool> used(nums.size(), false);
        sort(nums.begin(), nums.end());
        func(0, nums.size(), nums, used);
        return ans;
    }
};

十一、两种解空间树的对比

for (int j = i; j < n; j++) {
    func(j + 1);
}

image.png

for (int j = 0; j < n; j++) {
    func(i + 1);
}

image.png