暴力的藝術(shù):回溯算法

我的CSDN: ListerCi
我的簡(jiǎn)書: 東方未曦

一、回溯算法與DFS

回溯算法是暴力求解的一種,它能系統(tǒng)地搜索一個(gè)問題的所有解或者任意解。它通過深度優(yōu)先搜索遞歸地遍歷所有可能的解。遍歷到解空間的某個(gè)分支時(shí),如果確定當(dāng)前分支不滿足條件,那么就退回到上一步嘗試別的分支。這種回退到上一步的方式就是“回溯”,判斷分支是否有解就是“剪枝”。
如果你是一名新手,看完回溯的定義,你是心懷期待呢還是心亂如麻呢?其實(shí)回溯法是算法學(xué)習(xí)的入門,評(píng)價(jià)一個(gè)程序員是不是學(xué)過算法,看他會(huì)不會(huì)寫回溯就可以了。因此回溯算法頻繁地出現(xiàn)在各種筆試和面試中,當(dāng)你面對(duì)一個(gè)搜索問題時(shí),如果寫出了回溯法而不是一個(gè)又一個(gè)的for循環(huán),面試官也會(huì)覺得你的代碼眉清目秀的。
回溯算法的基礎(chǔ)是深度優(yōu)先搜索(DFS),這種搜索會(huì)在一個(gè)分支上一直深入,直到遍歷完該分支,之后再嘗試另外的分支。DFS的偽代碼如下,visited[i]數(shù)組代表i點(diǎn)是否訪問過。

void dfs(int v) {
    visited[v] = true;
    for (v的所有鄰接點(diǎn)w) {
        if (!visited[w])
            dfs(w);
    }
}

回溯算法就是在DFS的過程里加入了返回條件以及剪枝,下面讓我們來通過Leetcode真題一步一步地去理解它。

二、Leetcode回溯真題

1. 求解子集

Leetcode地址:78. 子集
題目的意思很簡(jiǎn)單,給你一個(gè)長(zhǎng)度為n的不包含重復(fù)數(shù)字的序列,返回該序列的所有子集。既然該序列不包含重復(fù)數(shù)字,那么子集的個(gè)數(shù)肯定是2^n(因?yàn)榍笞蛹瘯r(shí)對(duì)每個(gè)元素都有選或者不選兩個(gè)操作),算法的時(shí)間復(fù)雜度就是O(2^n)。
那么選和不選這兩種操作怎么體現(xiàn)在程序中呢?舉個(gè)例子,假設(shè)當(dāng)前的序列為[1, 2, 3],我們可以選擇將元素1添加到子集中,再求剩下的序列[2, 3]的所有子集;我們也可以選擇不將元素1添加到子集中,再求[2, 3]的所有子集。
這明顯是一個(gè)遞歸的過程:對(duì)第i個(gè)元素選擇之后,將結(jié)果保存,再對(duì)剩下的元素進(jìn)行選擇。對(duì)所有的元素選擇完畢之后,得到的結(jié)果就是一個(gè)子集。

// nums為需要求子集的序列
vector<vector<int> > subsets(vector<int>& nums) {
    vector<vector<int> > result; // 保存所有的子集
    vector<int> path; // 在搜索時(shí)保存當(dāng)前的子集
    search(0, nums, path, result); // 從第0個(gè)元素開始搜索
    return result;
}

// idx表示當(dāng)前搜索nums[idx]
void search(int idx, vector<int>& nums, vector<int>& path, vector<vector<int> >& result) {
    // 當(dāng)對(duì)所有元素選擇后,就得到了一個(gè)子集
    if (idx >= nums.size()) {
        result.push_back(path);
        return; // 此處一定要返回
    }
    // 選擇nums[idx] 
    path.push_back(nums[idx]);
    search(idx + 1, nums, path, result);
    // 不選擇nums[idx] 
    path.pop_back();
    search(idx + 1, nums, path, result);
}

2. 全排列

Leetcode地址:46. 全排列
題目給定一個(gè)沒有重復(fù)數(shù)字的序列,返回其所有可能的全排列。
上題求解子集時(shí)是對(duì)每個(gè)元素選擇是否添加到子集中,而求解全排列就是選擇添加哪個(gè)沒有被添加過的元素到當(dāng)前的排列中。因?yàn)橐袛嗄硞€(gè)元素是否被選取過,所以需要一個(gè)bool visited[i]數(shù)組判斷i元素是否已經(jīng)被添加到排列中。

例如求解[1, 2, 3]的全排列,流程如下:
① 將1添加到第一位,此時(shí)1已經(jīng)被訪問,則visited[1]=true,之后的元素只能從[2, 3]中選取,最終會(huì)形成1在第一位的所有全排列[1, 2, 3]和[1, 3, 2]。
② 此時(shí)1在第一位的所有情況都遍歷完成,需要將1的訪問狀態(tài)返還為未訪問,也就是visited[1]=false。這樣之后將其他數(shù)放在第一位時(shí),依舊可以選擇1跟在后面。
③ 將2添加到第一位,再跟上[1, 3]的全排列。

vector<vector<int> > permute(vector<int>& nums) {
    vector<vector<int> > result;
    vector<int> cur;
    int size = nums.size();
    // 如果nums為空,返回空結(jié)果
    if (size == 0)
        return result;
    // visited數(shù)組判斷當(dāng)前元素是否已經(jīng)添加到全排列中
    bool* visited = new bool[size];
    for (int i = 0; i < size; ++i)
        visited[i] = false;
    dfs(0, result, cur, nums, visited);
    return result;
}

// index表示當(dāng)前添加第index個(gè)數(shù)字到全排列中
void dfs(int index, vector<vector<int> >& result, vector<int> cur, vector<int>& nums, bool* visited) {
    if (index >= nums.size()) {
        result.push_back(cur);
        return;
    }
    for (int i = 0; i < nums.size(); ++i) {
        // 如果當(dāng)前元素未被添加到全排列中
        if (!visited[i]) {
            // 將當(dāng)前元素添加到全排列
            cur.push_back(nums[i]);
            visited[i] = true;
            dfs(index + 1, result, cur, nums, visited);
            // 返還狀態(tài)
            cur.pop_back();
            visited[i] = false;
        }
    }
}

3. 組合總和

Leetcode地址:39. 組合總和
給定一個(gè)無重復(fù)元素的數(shù)組 candidates 和一個(gè)目標(biāo)數(shù) target ,找出 candidates中所有可以使數(shù)字和為target 的組合。candidates中的數(shù)字可以無限制重復(fù)被選取。
在這道題中,數(shù)組中的元素可以重復(fù)選取,因此不需要visited[]來判斷某個(gè)數(shù)字是否被訪問過。在遍歷時(shí),如果確定將candidates[i]添加到組合中,那么可以將target-candidates[i]作為下一輪遞歸的新target,如果新target為0,那么之前的組合就是一個(gè)解;如果小于0,那么當(dāng)前分支不成立,退回到上一步;如果大于0,則可以再選取一個(gè)數(shù)字添加到組合中。

vector<vector<int> > combinationSum(vector<int>& candidates, int target) {
    vector<vector<int> > result;
    vector<int> path; // 用于保存臨時(shí)的組合
    sort(candidates.begin(), candidates.end()); // 排序
    search(0, result, candidates, path, target);
    return result;
}

void search(int idx, vector<vector<int> >& result, vector<int>& candidates, 
            vector<int>& path, int target) {
    if (target == 0) {
        result.push_back(path);
        return;
    }
    if (target < 0) {
        return;
    }
    //  依此遍歷可選元素
    for (int i = idx; i < candidates.size(); ++i) {
        path.push_back(candidates[i]);
        // 由于可以重復(fù)選取,因此下次遞歸仍舊可以從當(dāng)前元素開始
        search(i, result, candidates, path, target - candidates[i]);
        // 不選擇當(dāng)前元素,for循環(huán)向下遍歷
        path.pop_back();
    }
}

上述程序通過遞歸時(shí)判斷target是否小于0來確認(rèn)當(dāng)前分支是否已經(jīng)無解,這樣的“剪枝”方式未免太過粗糙。如果將該程序放入OJ中運(yùn)行,雖然可以通過,但是運(yùn)行時(shí)間只擊敗了大約28%的程序,這種情況下一定存在優(yōu)化的辦法。
仔細(xì)查看后發(fā)現(xiàn),candidates是經(jīng)過由小到大排序的,如果target-candidates[i] < 0,那么target-candidates[i + 1], target-candidates[i + 2]......都是小于0的。因此search()函數(shù)可以這么優(yōu)化。

void search(int idx, vector<vector<int> >& result, vector<int>& candidates, 
            vector<int>& path, int target) {
    if (target == 0) {
        result.push_back(path);
        return;
    }
    for (int i = idx; i < candidates.size(); ++i) {
        if (target - candidates[i] < 0) {
            break;
        } else {
            path.push_back(candidates[i]);
            search(i, result, candidates, path, target - candidates[i]);
            path.pop_back();
        }
    }
}

優(yōu)化之后,代碼的運(yùn)行時(shí)間擊敗了98%以上的程序,當(dāng)真是細(xì)節(jié)決定成敗。

4. 組合總和變體

Leetcode地址:40. 組合總和 II
這道題與上一道有不少區(qū)別,一是數(shù)字可能重復(fù),二是每個(gè)數(shù)字只能取一次。如果你認(rèn)為將上一題解答中的search(i, result...)改為search(i + 1, result...)就可以的話,那你就錯(cuò)了。
如果只是這么修改,在candidates為[1, 1, 3]和target為4的情況下,結(jié)果中會(huì)出現(xiàn)兩個(gè)[1, 3],因此我們需要?jiǎng)h掉重復(fù)結(jié)果。使用set是一個(gè)辦法,但是效率低下,我們可以在外層遍歷時(shí)跳過重復(fù)的數(shù)字從而達(dá)到跳過重復(fù)解的效果。

vector<vector<int> > combinationSum2(vector<int>& candidates, int target) {
    vector<vector<int> > result;
    vector<int> path;
    sort(candidates.begin(), candidates.end());
    search(0, result, path, candidates, target);
    return result;
}

void search(int idx, vector<vector<int> >& result, vector<int>& path, 
            vector<int>& candidates, int target) {
    if (target == 0) {
        result.push_back(path);
        return;
    }
    for (int i = idx; i < candidates.size(); ++i) {
        if (i > idx && candidates[i] == candidates[i - 1])
            continue;
        if (target - candidates[i] < 0) {
            break;
        } else {
            path.push_back(candidates[i]);
            search(i + 1, result, path, candidates, target - candidates[i]);
            path.pop_back();
        }
    }
}

如果你一時(shí)無法理解,可以在紙上模擬一下代碼的流程。

三、八皇后

講到回溯就繞不開八皇后問題,因?yàn)樗鼘?shí)在是太經(jīng)典了,Leetcode上有一道變種的N皇后問題,讓我們來一探究竟。
Leetcode地址:51. N皇后
國(guó)際象棋中,皇后可以攻擊同一直線和同一斜線的棋子。如果想要在N*N的棋盤上放置N個(gè)皇后,使它們之間互不攻擊,就意味著每行、每列、每條斜線上都只有一個(gè)皇后。下圖就是八皇后的一個(gè)解。

8-queens.png

一個(gè)N*N的棋盤,顯然只有N行N列,那么每一行每一列上都會(huì)有一個(gè)皇后。那么這個(gè)棋盤有多少條斜線呢?初中數(shù)學(xué)告訴我們,正斜線和反斜線都有2N-1條。其中正斜線的斜率都為1,反斜線的斜率都為-1。
正斜線的公式為y = x + k1,反斜線的公式為y = -x + k2。其中,k1和k2的取值都有2N-1種,每個(gè)k1都能確定一條正斜線,每個(gè)k2都能確定一條反斜線。所以使用兩個(gè)數(shù)組就能確定每條斜線是否已經(jīng)被一個(gè)皇后占據(jù)了。(上述的斜率是在標(biāo)準(zhǔn)坐標(biāo)系中的,二維數(shù)組的坐標(biāo)系并不是如此。)
假設(shè)當(dāng)前需要在(i, j)放置一個(gè)皇后,那么i所代表的行和j所代表的列必須沒有被占據(jù),而i - ji + j所代表的兩條斜線也必須沒有被占據(jù)。為了節(jié)省空間,我們使用一維數(shù)組path[]來存儲(chǔ)皇后的擺放情況,path[i] = val代表ival列有一個(gè)皇后。因?yàn)槊啃卸急仨氂幸粋€(gè)皇后,可以直接遍歷行來存放。

vector<vector<string> > solveNQueens(int n) {
    vector<vector<string> > result;
    // path[i] = val 代表i行val列有一個(gè)皇后
    int* path = new int[n];
    // 標(biāo)記某一列是否被占據(jù)
    bool* visited = new bool[n];
    for (int i = 0; i < n; ++i)
        visited[i] = false;
    // 標(biāo)記兩條斜線是否被占據(jù)
    bool* slash1 = new bool[2 * n];
    bool* slash2 = new bool[2 * n];
    for (int i = 0; i < 2 * n; ++i) {
        slash1[i] = false;
        slash2[i] = false;
    }
    search(0, result, path, n, visited, slash1, slash2);
    return result;
}

void search(int idx, vector<vector<string> >& result, int* path, int n, 
            bool* visited, bool* slash1, bool* slash2) {
    // 這里是生成返回結(jié)果的,不重要
    if (idx >= n) {
        vector<string> tmp_result;
        for (int i = 0; i < n; ++i) {
            // 每一行
            char* tmp = new char[n + 1];
            for (int j = 0; j < n; ++j) {
                if (path[i] == j)
                    tmp[j] = 'Q';
                else
                    tmp[j] = '.';
            }
            tmp[n] = '\0';
            string tmp_string(tmp);
            tmp_result.push_back(tmp_string);
        }
        result.push_back(tmp_result);
        return;
    }
    // 在每一行添加一個(gè)皇后
    for (int i = 0; i < n; ++i) {
        if (!visited[i] && !slash1[idx + i] && !slash2[idx - i + n]) {
            // 該位置合法,嘗試在這里擺放一個(gè)皇后
            path[idx] = i;
            visited[i] = true;
            slash1[idx + i] = true;
            slash2[idx - i + n] = true;
            search(idx + 1, result, path, n, visited, slash1, slash2);
            // 不在此處擺放,返還狀態(tài)
            visited[i] = false;
            slash1[idx + i] = false;
            slash2[idx - i + n] = false;
        }
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容