力扣高頻|算法面試題彙總(十):圖論
力扣鏈接
目錄:
- 1.單詞接龍
- 2.島嶼數量
- 3.課程表
- 4.課程表 II
1.單詞接龍
給定兩個單詞(beginWord 和 endWord)和一個字典,找到從 beginWord 到 endWord 的最短轉換序列的長度。轉換需遵循如下規則:
每次轉換隻能改變一個字母。
轉換過程中的中間單詞必須是字典中的單詞。
說明:
如果不存在這樣的轉換序列,返回 0。
所有單詞具有相同的長度。
所有單詞只由小寫字母組成。
字典中不存在重複的單詞。
你可以假設 beginWord 和 endWord 是非空的,且二者不相同。
示例 1:
輸入:
beginWord = “hit”,
endWord = “cog”,
wordList = [“hot”,“dot”,“dog”,“lot”,“log”,“cog”]
輸出: 5
解釋: 一個最短轉換序列是 “hit” -> “hot” -> “dot” -> “dog” -> “cog”,
返回它的長度 5。
思路:
參考官方思路:廣度優先搜索
用一個圖來模擬整個流程,擁有一個 beginWord
和一個 endWord
,分別表示圖上的 start node
和 end node
。中間節點是 wordList
給定的單詞。對這個單詞接龍每個步驟的唯一條件是相鄰單詞只可以改變一個字母。
將問題抽象在一個無向無權圖中,每個單詞作爲節點,差距只有一個字母的兩個單詞之間連一條邊。問題變成找到從起點到終點的最短路徑,如果存在的話。因此可以使用廣度優先搜索方法。算法中最重要的步驟是找出相鄰的節點,也就是隻差一個字母的兩個單詞。爲了快速的找到這些相鄰節點,對給定的 wordList 做一個預處理,將單詞中的某個字母用 * 代替
這個預處理構造了一個單詞變換的通用狀態。例如:Dog ----> D*g <---- Dig
,Dog
和 Dig
都指向了一個通用狀態 D*g
。這步預處理找出了單詞表中所有單詞改變某個字母后的通用狀態,並更方便也更快的找到相鄰節點。否則,對於每個單詞需要遍歷整個字母表查看是否存在一個單詞與它相差一個字母,這將花費很多時間。預處理操作在廣度優先搜索之前高效的建立了鄰接表。
在廣搜時需要訪問 Dug 的所有鄰接點,可以先生成 Dug 的所有通用狀態:
- 1.
Dug => *ug
- 2.
Dug => D*g
- 3.
Dug => Du*
第二個變換 D*g
可以同時映射到 Dog
或者 Dig
,因爲他們都有相同的通用狀態。擁有相同的通用狀態意味着兩個單詞只相差一個字母,他們的節點是相連的。
算法步驟:
- 1.先對給定的
wordList
進行預處理,將通用狀態記錄下來,鍵是通用狀態,值是所有具有通用狀態的單詞。 - 2.將包含
beginWord
和1
成對放入隊列中,需要返回endWord
的層次也就是從beginWord
出發的最短距離。 - 3.使用
visited
記錄訪問的節點,避免重複訪問,出現環。 - 4.當隊列中有元素的時候,取出第一個元素,記爲
current_word
。 - 5.找到
current_word
的所有通用狀態,並檢查這些通用狀態是否存在其它單詞的映射,這一步通過檢查all_combo_dict
來實現。 - 6.從
all_combo_dict
獲得的所有單詞,都和current_word
共有一個通用狀態,所以都和current_word
相連,因此將他們加入到隊列中。 - 7.對於新獲得的所有單詞,向隊列中加入元素
(word, level + 1)
其中level
是current_word
的層次。 - 8.最終到達期望的單詞,對應的層次就是最短變換序列的長度。標準廣度優先搜索的終止條件就是找到結束單詞。
複雜度分析:
時間複雜度:,其中 是單詞的長度 是單詞表中單詞的總數。找到所有的變換需要對每個單詞做 次操作。同時,最壞情況下廣度優先搜索也要訪問所有的 個單詞。
空間複雜度:,要在 all_combo_dict
字典中記錄每個單詞的 個通用狀態。訪問數組的大小是 。廣搜隊列最壞情況下需要存儲 個單詞。
C++
class Solution {
public:
int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
if(find(wordList.begin(), wordList.end(), endWord) == wordList.end()) return 0;
// 可重複map 記錄通用狀態
unordered_multimap<string, string> all_combo_dict;
unordered_set<string> visited;
for(auto word : wordList){
string str = word;
for(int i = 0; i < word.size(); ++i){
str[i] = '*';
all_combo_dict.emplace(str, word);
str[i] = word[i];
}
}
// 構造隊列
queue<string> wordQueue;
wordQueue.push(beginWord); // 添加第一個元素
int level = 1;
while(!wordQueue.empty()){
++level;
int length = wordQueue.size();
while(length--){
string cur = wordQueue.front(); // 獲取隊列中的第一個元素
wordQueue.pop();
for(int i = 0; i < cur.size(); ++i){
char tmp = cur[i];
cur[i] = '*'; // 修改成通用形式
// equal_range 返回範圍[first,last)內等於指定值val的子範圍的迭代器。
// 注意的是使用這個函數的前提是範圍[first,last)內的元素是有序的。
// 同時注意函數的返回值類型,返回值是個pair對象,pair的first是左邊界的迭代器,
// pair的second是右邊界的迭代器。
// 區間是左閉右開的,[左邊界,右邊界)。
auto range = all_combo_dict.equal_range(cur);
for(auto itear = range.first; itear != range.second; ++itear){
if(visited.count(itear->second) == 0){ // 如果還沒有訪問
if(itear->second == endWord) return level; // 如果找到,返回結果
wordQueue.push(itear->second);
visited.emplace(itear->second);
}
}
cur[i] = tmp; // 還原
}
}
}
return 0;
}
};
Python
# defaultdict構造有默認輸出的字典
from collections import defaultdict
class Solution(object):
def ladderLength(self, beginWord, endWord, wordList):
"""
:type beginWord: str
:type endWord: str
:type wordList: List[str]
:rtype: int
"""
if not endWord in wordList or not beginWord or not endWord or not wordList:
return 0
# 獲取單詞的長度
length = len(beginWord)
# 字典用來存放任何給定單詞的組合詞。一次換一個字母
all_combo_dict = defaultdict(list)
for word in wordList:
for i in range(length):
# 鍵是通用詞
# 值是具有相同中間泛型單詞的單詞列表
all_combo_dict[word[:i] + "*" + word[i+1:]].append(word)
# 隊列BFS
queue = [(beginWord, 1)]
# Visited以確保不會重複處理相同的字
visited = {beginWord: True}
while queue:
current_word, level = queue.pop(0)
for i in range(length):
# 現在詞的中間詞
intermediate_word = current_word[:i] + "*" + current_word[i+1:]
# 下一個狀態是所有中間狀態相同的詞。
for word in all_combo_dict[intermediate_word]:
# 如果在任何時候,如果找到要找的東西,即結束詞,可以返回答案。
if word == endWord:
return level + 1
# 否則,將其添加到BFS隊列。也標誌着它訪問
if word not in visited:
visited[word] = True
queue.append((word, level + 1))
all_combo_dict[intermediate_word] = [] # 有visited 這個可以不加
return 0
思路2:
參考官方思路:雙向廣度優先搜索
在思路1中,根據給定字典構造的圖可能會很大,而廣度優先搜索的搜索空間大小依賴於每層節點的分支數量。假如每個節點的分支數量相同,搜索空間會隨着層數的增長指數級的增加。
如果使用兩個同時進行的廣搜可以有效地減少搜索空間。一邊從 beginWord
開始,另一邊從 endWord
開始。每次從兩邊各擴展一個節點,當發現某一時刻兩邊都訪問了某一頂點時就停止搜索。這就是雙向廣度優先搜索,它可以可觀地減少搜索空間大小,從而降低時間和空間複雜度。
算法步驟:
- 1.算法核心和思路1相似,不過從兩個節點同時開始搜索,同時搜素的結束條件也有所變化。
- 2.使用兩個訪問數組,分別記錄從對應的起點是否已經訪問了該節點。
- 3.如果發現一個節點被兩個搜索同時訪問,就結束搜索過程。因爲找到了雙向搜索的交點。過程如同從中間相遇而不是沿着搜索路徑一直走。雙向搜索的結束條件是找到一個單詞被兩邊搜索都訪問過了。
- 4.最短變換序列的長度就是中間節點在兩邊的層次之和。因此可以在訪問數組中記錄節點的層次。
複雜度分析:
時間複雜度:,其中 是單詞的長度 是單詞表中單詞的總數。找到所有的變換需要對每個單詞做 次操作。但是搜索時間會被縮小一半,因爲兩個搜索會在中間某處相遇。
空間複雜度:,要在 all_combo_dict
字典中記錄每個單詞的 個通用狀態。訪問數組的大小是 。但是因爲會在中間相遇,所以雙向搜索的搜索空間變小。
C++
class Solution {
public:
unordered_map<string, vector<string>> all_combo_dict;
int length;
int visitWordNode(queue<pair<string, int>>& que,
unordered_map<string, int>& visited,
unordered_map<string, int>& others_visited){
string current_word = que.front().first;
int level = que.front().second;
que.pop();
for(int i = 0; i < length; ++i){
string index = current_word.substr(0, i)+"*"+current_word.substr(i+1, length);
for(auto str : all_combo_dict[index]){
if(others_visited[str])
return level + others_visited[str];
if(!visited[str]){
que.push(make_pair(str, level+1));
visited[str] = level + 1;
}
}
}
return -1;
}
int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
if(find(wordList.begin(), wordList.end(), endWord) == wordList.end()) return 0;
unordered_set<string> visited;
for(auto word : wordList){ /*構造通用字典*/
string str = word;
for(int i = 0; i < word.size(); ++i){
str[i] = '*';
all_combo_dict[str].push_back(word);
str[i] = word[i];
}
}
length = beginWord.size();
// 構造雙向BFS隊列
queue<pair<string, int>> queue_beginWord; // BFS從beginWord開始
queue_beginWord.push(make_pair(beginWord, 1));
queue<pair<string, int>> queue_endWord; // BFS從endWord開始
queue_endWord.push(make_pair(endWord, 1));
// visited標誌,確保不會重複處理相同的字
unordered_map<string, int> visited_begin;
unordered_map<string, int> visited_end;
visited_begin[beginWord] = 1;
visited_end[endWord] = 1;
int res;
// 做一個雙向搜索,從BFS從beginWord開始開始一個指針,
// 從endWord開始一個指針。一個接一個地跳。
while(!queue_beginWord.empty() && !queue_endWord.empty()){
res = visitWordNode(queue_beginWord, visited_begin, visited_end);
if(res != -1) return res;
res = visitWordNode(queue_endWord, visited_end, visited_begin);
if(res != -1) return res;
}
return 0;
}
};
Python
# defaultdict構造有默認輸出的字典
from collections import defaultdict
class Solution(object):
# 初始化
def __init__(self):
self.length = 0 # 每個單詞的長度
# 字典用來存放任何給定單詞的組合詞。一次換一個字母。
self.all_combo_dict = defaultdict(list)
def ladderLength(self, beginWord, endWord, wordList):
"""
:type beginWord: str
:type endWord: str
:type wordList: List[str]
:rtype: int
"""
if not endWord in wordList: return 0
self.length = len(beginWord) # 因爲所有單詞的長度都是一樣的。
# 構建字典
for word in wordList:
for i in range(self.length):
# 鍵是通用詞
# 值是具有相同中間泛型單詞的單詞列表
self.all_combo_dict[word[:i] + '*' + word[i+1:]].append(word)
# 雙向BFS隊列
queue_begin = [(beginWord, 1)] # BFS從beginWord開始
queue_end = [(endWord, 1)] # BFS從endWord開始
# 訪問標誌,以確保不會重複處理相同的字
visited_begin = {beginWord: 1}
visited_end = {endWord: 1}
ans = None
# 做一個雙向搜索,從BFS從beginWord開始開始一個指針,
# 從endWord開始一個指針。一個接一個地跳。
while queue_begin and queue_end:
# 從begin word 開始
ans = self.visitWordNode(queue_begin, visited_begin, visited_end)
if ans:
return ans
# 從end word 開始
ans = self.visitWordNode(queue_end, visited_end, visited_begin)
if ans:
return ans
return 0
def visitWordNode(self, queue, visited, other_visited):
current_word, level = queue.pop(0)
for i in range(self.length):
# 現在詞的中間詞
intermediate_word = current_word[:i] + "*" + current_word[i+1:]
# 下一個狀態是所有中間狀態相同的詞。
for word in self.all_combo_dict[intermediate_word]:
# 如果中間狀態/單詞已經從另一個並行遍歷訪問過,這意味着找到了答案。
if word in other_visited:
return level + other_visited[word]
if word not in visited:
# 將level另存爲字典的值,以節省躍點數。
visited[word] = level + 1
queue.append((word, level + 1))
return None
2.島嶼數量
給你一個由 ‘1’(陸地)和 ‘0’(水)組成的的二維網格,請你計算網格中島嶼的數量。
島嶼總是被水包圍,並且每座島嶼只能由水平方向或豎直方向上相鄰的陸地連接形成。
此外,你可以假設該網格的四條邊均被水包圍。
示例 :
輸入:
11000
11000
00100
00011
輸出: 3
解釋: 每座島嶼只能由水平和/或豎直方向上相鄰的陸地連接而成。
思路:
參考官方思路,深度優先遍歷。
總結一下:
- 1.將二維網格看成一個無向圖,豎直或水平相鄰的
1
之間有邊相連。 - 2.掃面整個網絡,如果網格值爲
1
,則以此爲起點進行深度優先遍歷(上下左右),並將遍歷過的網格的值賦值爲0
。 - 3.有多少次遍歷,就有多少個島嶼。
參考圖示:
複雜度分析:
時間複雜度:,其中 和 分別爲行數和列數。
空間複雜度:,在最壞情況下,整個網格均爲陸地,深度優先搜索的深度達到 。
C++
class Solution {
public:
void dfs(vector<vector<char>>& grid, int row, int col){
int n_row = grid.size();
int n_col = grid[0].size();
// 先清0
grid[row][col] = '0';
// dfs遍歷上下左右
if(row - 1 >= 0 && grid[row-1][col] == '1') dfs(grid, row - 1, col);
if(row + 1 < n_row && grid[row+1][col] == '1') dfs(grid, row + 1, col);
if(col - 1 >= 0 && grid[row][col-1] == '1') dfs(grid, row, col - 1);
if(col + 1 < n_col && grid[row][col+1] == '1') dfs(grid, row, col + 1);
}
int numIslands(vector<vector<char>>& grid) {
int n_row = grid.size(); // 獲取行數
if(!n_row) return 0;
int n_col = grid[0].size(); // 獲取列數
int num_islands = 0; // 島嶼數
for(int row = 0; row < n_row; ++row){
for(int col = 0; col < n_col; ++col)
if(grid[row][col] == '1'){
++num_islands; // 島嶼數加1
dfs(grid, row, col); // dfs遍歷,把1變0
}
}
return num_islands;
}
};
Python:
class Solution:
def numIslands(self, grid: List[List[str]]) -> int:
n_row = len(grid)
if n_row == 0: return 0
n_col = len(grid[0])
num_islands = 0
for row in range(n_row):
for col in range(n_col):
if grid[row][col] == '1':
num_islands += 1
self.dfs(grid, row, col)
return num_islands
def dfs(self, grid, row, col):
n_row = len(grid)
n_col = len(grid[0])
grid[row][col] = '0'
if row - 1 >= 0 and grid[row-1][col] == '1': self.dfs(grid, row-1, col)
if row + 1 < n_row and grid[row+1][col] == '1': self.dfs(grid, row+1, col)
if col - 1 >= 0 and grid[row][col-1] == '1': self.dfs(grid, row, col-1)
if col + 1 < n_col and grid[row][col+1] == '1': self.dfs(grid, row, col+1)
思路2:
參考官方思路,廣度優先遍歷。
和深度優先遍歷類似,只不過把遍歷方式轉換成了廣度優先遍歷,遍歷過的位置,會把1變成0。有多少次遍歷,就有多少個島嶼。
複雜度分析
時間複雜度:,其中 和 分別爲行數和列數。
空間複雜度: ,在最壞情況下,整個網格均爲陸地,隊列的大小可以達到 。
C++
class Solution {
public:
int numIslands(vector<vector<char>>& grid) {
int n_row = grid.size(); // 獲取行數
if(!n_row) return 0;
int n_col = grid[0].size(); // 獲取列數
int num_islands = 0; // 島嶼數
// 使用bfs搜素
for(int row = 0; row < n_row; ++row){
for(int col = 0; col < n_col; ++col){
if(grid[row][col] == '1'){
++num_islands;
grid[row][col] = '0';
// 新建隊列進行廣度搜素
queue<pair<int, int>> neighbors;
neighbors.push(make_pair(row, col));
while(!neighbors.empty()){
auto row_col = neighbors.front(); // 獲取隊列的第一個元素
neighbors.pop();
int cur_row = row_col.first;
int cur_col = row_col.second;
// 四個方向
if(cur_row - 1 >= 0 && grid[cur_row-1][cur_col] == '1'){
neighbors.push(make_pair(cur_row-1, cur_col));
grid[cur_row-1][cur_col] = '0';
}
if(cur_row + 1 < n_row && grid[cur_row+1][cur_col] == '1'){
neighbors.push(make_pair(cur_row+1, cur_col));
grid[cur_row+1][cur_col] = '0';
}
if(cur_col - 1 >= 0 && grid[cur_row][cur_col-1] == '1'){
neighbors.push(make_pair(cur_row, cur_col-1));
grid[cur_row][cur_col-1] = '0';
}
if(cur_col + 1 < n_col && grid[cur_row][cur_col+1] == '1'){
neighbors.push(make_pair(cur_row, cur_col+1));
grid[cur_row][cur_col+1] = '0';
}
}
}
}
}
return num_islands;
}
};
Python:
class Solution:
def numIslands(self, grid: List[List[str]]) -> int:
n_row = len(grid)
if n_row == 0: return 0
n_col = len(grid[0])
num_islands = 0
for row in range(n_row):
for col in range(n_col):
if grid[row][col] == '1':
num_islands += 1
grid[row][col] = "0"
neighbors = collections.deque([(row, col)])
while neighbors:
cur_row, cur_col = neighbors.popleft() # 將最左邊的元素取出
for x, y in [(cur_row-1, cur_col), (cur_row+1, cur_col), (cur_row, cur_col-1), (cur_row, cur_col+1)]:
if 0<=x<n_row and 0<=y<n_col and grid[x][y] == '1':
neighbors.append((x, y))
grid[x][y] = '0'
return num_islands
3.課程表
你這個學期必須選修 numCourse 門課程,記爲 0 到 numCourse-1 。
在選修某些課程之前需要一些先修課程。 例如,想要學習課程 0 ,你需要先完成課程 1 ,用一個匹配來表示他們:[0,1]
給定課程總量以及它們的先決條件,請你判斷是否可能完成所有課程的學習?
示例 1:
輸入: 2, [[1,0]]
輸出: true
解釋: 總共有 2 門課程。學習課程 1 之前,你需要完成課程 0。所以這是可能的。
提示:
輸入的先決條件是由 邊緣列表 表示的圖形,而不是 鄰接矩陣 。詳情請參見圖的表示法。
你可以假定輸入的先決條件中沒有重複的邊。
1 <= numCourses <= 10^5
思路:
參考講解:入度表(廣度優先遍歷)
首先本題可以轉化爲:課程安排圖是否是有向無環圖(DAG)。即課程間規定了前置條件,但不能構成任何環路,否則課程前置條件將不成立。通過 拓撲排序 判斷此課程安排圖是否是 有向無環圖(DAG) 。通過題目條件numCourses
,構建鄰接表,以降低算法時間複雜度。
算法步驟:
1.構建鄰接表adjList
,以及記錄每個節點的入度indegrees
。
2.創建一個隊列,把所有入度爲0的節點入隊。
3.進行BFS 拓撲排序: 將此節點對應的所有節點的入度-1,即indegrees[cur] -= 1
,如果減一之後,對應的節點的入度爲0,說明 cur 所有的前驅節點已經被 “刪除”,則放入隊列中。
4.在每次 pre
出隊時,執行 numCourses--
;若整個課程安排圖是有向無環圖(即可以安排),則所有節點一定都入隊並出隊過,即完成拓撲排序。換個角度說,若課程安排圖中存在環,一定有節點的入度始終不爲 0
。因此,拓撲排序出隊次數等於課程個數,返回 numCourses == 0
判斷課程是否可以成功安排。、
參考圖示:
複雜度分析:
時間複雜度 : 遍歷一個圖需要訪問所有節點和所有臨邊, 和 分別爲節點數量和臨邊數量;
空間複雜度 : 爲建立鄰接表所需額外空間,adjList 長度爲 ,並存儲 條臨邊的數據。
C++
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<int> indegrees(numCourses);
vector<vector<int>> adjList(numCourses);
queue<int> nodes;
// 獲得每門課程入度和鄰接表
for(int i = 0; i < prerequisites.size(); ++i){
++indegrees[prerequisites[i][0]];
adjList[prerequisites[i][1]].push_back(prerequisites[i][0]);
}
// 獲得所有入度爲0的課程
for(int i = 0; i < numCourses; ++i){
if(indegrees[i] == 0)
nodes.push(i);
}
// BFS 拓撲排序
while(!nodes.empty()){
int pre = nodes.front();
nodes.pop();
--numCourses;
for(int i = 0; i < adjList[pre].size(); ++i){
--indegrees[adjList[pre][i]];
if(indegrees[adjList[pre][i]] == 0) nodes.push(adjList[pre][i]);
}
}
return numCourses == 0;
}
};
Python
from collections import deque
class Solution:
def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
indegrees = [0 for _ in range(numCourses)] # 給每個節點分配入度
adjList = [[] for _ in range(numCourses)] # 構建鄰接表
queue = deque() # 新建隊列
# 獲得每門課程入度和鄰接表
for cur, pre in prerequisites:
indegrees[cur] += 1
adjList[pre].append(cur)
# 獲得所有入度爲0的課程
for i in range(numCourses):
if indegrees[i] == 0: queue.append(i)
# BFS 拓撲排序
while queue:
pre = queue.popleft() # 將最左邊的元素取出
numCourses -= 1 # 課程數量減一
for cur in adjList[pre]: # 獲取鏈接pre的所有節點
indegrees[cur] -= 1 # 入度減一
if indegrees[cur] == 0: queue.append(cur) # 如果入度爲0,則加入隊列
return numCourses == 0
思路2:
參考講解:深度優先遍歷
1.藉助一個標誌列表 flags,用於判斷每個節點 i (課程)的狀態:
- 未被 DFS 訪問:
i == 0
; - 已被其他節點啓動的 DFS 訪問:
i == -1
; - 已被當前節點啓動的 DFS 訪問:
i == 1
。
2.對 numCourses
個節點依次執行 DFS,判斷每個節點起步 DFS 是否存在環,若存在環直接返回 False。DFS 流程;
- 終止條件:
當flag[i] == -1
,說明當前訪問節點已被其他節點啓動的 DFS 訪問,無需再重複搜索,直接返回 True。
當flag[i] == 1
,說明在本輪 DFS 搜索中節點 i 被第 22 次訪問,即 課程安排圖有環 ,直接返回False。 - 將當前訪問節點
i
對應flag[i]
置 1,即標記其被本輪 DFS 訪問過; - 遞歸訪問當前節點
i
的所有鄰接節點j
,當發現環直接返回 False; - 當前節點所有鄰接節點已被遍歷,並沒有發現環,則將當前節點
flag
置爲 -1並返回 True。
3.若整個圖 DFS 結束並未發現環,返回 True。
C++
class Solution {
public:
bool dfs(int idNode, vector<vector<int>>& adjList, vector<int>& flags){
if(flags[idNode] == 1) return false;
if(flags[idNode] == -1) return true;
flags[idNode] = 1;
for(int i = 0; i < adjList[idNode].size(); ++i)
if(!dfs(adjList[idNode][i], adjList, flags)) return false;
flags[idNode] = -1;
return true;
}
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<int> flags(numCourses);
vector<vector<int>> adjList(numCourses);
for(int i = 0; i < prerequisites.size(); ++i){
adjList[prerequisites[i][1]].push_back(prerequisites[i][0]);
}
for(int i = 0; i < numCourses; ++i){
if(!dfs(i, adjList, flags)) return false;
}
return true;
}
};
Python
from collections import deque
class Solution:
def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
def dfs(idNode, adjList, flags):
# 在本輪 DFS 搜索中節點 i 被第 2次訪問,即 課程安排圖有環
if flags[idNode] == 1: return False
# 當前訪問節點已被其他節點啓動的 DFS 訪問,無需再重複搜索
if flags[idNode] == -1: return True
flags[idNode] = 1 # 已被當前節點啓動的 DFS 訪問
for nextNode in adjList[idNode]:
if not dfs(nextNode, adjList, flags): return False
flags[idNode] = -1 # 記爲-1,避免重複搜索
return True
adjList = [[] for _ in range(numCourses)] # 構建鄰接表
flags = [0 for _ in range(numCourses)] # 訪問標誌
for cur, pre in prerequisites:
adjList[pre].append(cur) # 創建鄰接表
for i in range(numCourses): # 從0 ~ numCourses-1 出發遍歷
if not dfs(i, adjList, flags): return False
return True
4.課程表 II
現在你總共有 n 門課需要選,記爲 0 到 n-1。
在選修某些課程之前需要一些先修課程。 例如,想要學習課程 0 ,你需要先完成課程 1 ,用一個匹配來表示他們: [0,1]
給定課程總量以及它們的先決條件,返回你爲了學完所有課程所安排的學習順序。
可能會有多個正確的順序,你只要返回一種就可以了。如果不可能完成所有課程,返回一個空數組。
示例 :
輸入: 4, [[1,0],[2,0],[3,1],[3,2]]
輸出: [0,1,2,3] or [0,2,1,3]
解釋: 總共有 4 門課程。要學習課程 3,你應該先完成課程 1 和課程 2。並且課程 1 和課程 2 都應該排在課程 0 之後。
因此,一個正確的課程順序是 [0,1,2,3] 。另一個正確的排序是 [0,2,1,3] 。
說明:
輸入的先決條件是由邊緣列表表示的圖形,而不是鄰接矩陣。詳情請參見圖的表示法。
你可以假定輸入的先決條件中沒有重複的邊。
提示:
這個問題相當於查找一個循環是否存在於有向圖中。如果存在循環,則不存在拓撲排序,因此不可能選取所有課程進行學習。
通過 DFS 進行拓撲排序 - 一個關於Coursera的精彩視頻教程(21分鐘),介紹拓撲排序的基本概念。
拓撲排序也可以通過 BFS 完成。
思路:
參考官方思路:深度優先搜索。
可以將本題建模成一個求拓撲排序的問題了:
- 將每一門課看成一個節點;
- 如果想要學習課程
A
之前必須完成課程B
,那麼從B
到A
連接一條有向邊。這樣以來,在拓撲排序中,B
一定出現在A
的前面。
可以將深度優先搜索的流程與拓撲排序的求解聯繫起來,用一個棧來存儲所有已經搜索完成的節點。
對於一個節點 u
,如果它的所有相鄰節點都已經搜索完成,那麼在搜索回溯到 u
的時候,u
本身也會變成一個已經搜索完成的節點。這裏的「相鄰節點」指的是從 u
出發通過一條有向邊可以到達的所有節點。
算法步驟:
對於圖中的任意一個節點,它在搜索的過程中有三種狀態,即:
- 未搜索:還沒有搜索到這個節點;
- 搜索中:搜索過這個節點,但還沒有回溯到該節點,即該節點還沒有入棧,還有相鄰的節點沒有搜索完成);
- 已完成:搜索過並且回溯過這個節點,即該節點已經入棧,並且所有該節點的相鄰節點都出現在棧的更底部的位置,滿足拓撲排序的要求
通過上述的三種狀態,可以給出使用深度優先搜索得到拓撲排序的算法流程,在每一輪的搜索搜索開始時,任取一個未搜索的節點開始進行深度優先搜索。
將當前搜索的節點 u
標記爲搜索中,遍歷該節點的每一個相鄰節點 v
:
- 如果
v
爲未搜索,那麼開始搜索v
,待搜索完成回溯到u
; - 如果
v
爲搜索中,那麼就找到了圖中的一個環,因此是不存在拓撲排序的; - 如果
v
爲已完成,那麼說明v
已經在棧中了,而u
還不在棧中,因此u
無論何時入棧都不會影響到(u, v)
之前的拓撲關係,以及不用進行任何操作
u
的所有相鄰節點都爲已完成時,將 u
放入棧中,並將其標記爲已完成。
在整個深度優先搜索的過程結束後,如果沒有找到圖中的環,那麼棧中存儲這所有的 nn 個節點,從棧頂到棧底的順序即爲一種拓撲排序。參考圖示:圖中的「白色」「黃色」「綠色」節點分別表示「未搜索」「搜索中」「已完成」的狀態。
複雜度分析:
時間複雜度: ,其中 爲課程數, 爲先修課程的要求數。這其實就是對圖進行深度優先搜索的時間複雜度。
空間複雜度: 。題目中是以列表形式給出的先修課程關係,爲了對圖進行深度優先搜索,需要存儲成鄰接表的形式,空間複雜度爲 。在深度優先搜索的過程中,需要最多 的棧空間(遞歸)進行深度優先搜索,並且還需要若干個 的空間存儲節點狀態、最終答案等。
C++
class Solution {
private:
vector<vector<int>> edges; /*存儲有向圖的每一條邊*/
vector<int> visited; /*存儲每個節點的訪問狀態, 0=未搜索,1=搜索中,2=已完成*/
vector<int> res; /*用數組來模擬棧,下標0爲棧底*/
bool invalid; /*判斷有向圖中是否有環*/
public:
void dfs(int node){
visited[node] = 1;/*將節點標記爲 搜索中*/
// 搜索其相鄰節點
// 只要發現有環,立刻停止搜索
for(int v : edges[node]){
// 如果「未搜索」那麼搜索相鄰節點
if(visited[v] == 0){
dfs(v);
if(invalid) return;
}// 如果「搜索中」說明找到了環
else if(visited[v] == 1){
invalid = true;
return;
}
}
// 將節點標記爲「已完成」
visited[node] = 2;
// 將節點入棧
res.push_back(node);
}
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
edges.resize(numCourses); /*初始化*/
visited.resize(numCourses);
// 創建有向圖
for (const auto& info: prerequisites) {
edges[info[1]].push_back(info[0]);
}
// 每次挑選一個 未搜索 的節點,開始進行深度優先搜索
for(int i = 0; i < numCourses && !invalid; ++i){
if(!visited[i]) dfs(i); /*如果還未訪問,則進行深度優先遍歷*/
}
if(invalid) return {}; /*如果有環,則返回空列表*/
// 如果沒有環,那麼就有拓撲排序
// 注意下標 0 爲棧底,因此需要將數組反序輸出
reverse(res.begin(), res.end());
return res;
}
};
Python
class Solution:
def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
# 存儲有向圖
edges = collections.defaultdict(list)
# 標記每個節點的狀態:0=未搜索,1=搜索中,2=已完成
visited = [0] * numCourses
# 用數組來模擬棧,下標 0 爲棧底,n-1 爲棧頂
res = list()
# 判斷有向圖中是否有環
invalid = False
for info in prerequisites:
edges[info[1]].append(info[0])
def dfs(node):
nonlocal invalid
# 將節點標記爲 搜索中
visited[node] = 1
for v in edges[node]:
# 如果 未搜索 ,那麼搜索相鄰節點
if visited[v] == 0:
dfs(v)
if invalid:
return
# 如果 搜索中 說明找到了環
elif visited[v] == 1:
invalid = True
return
# 將節點標記爲 已完成
visited[node] = 2
# 將節點入棧
res.append(node)
# 每次挑選一個 未搜索 的節點,開始進行深度優先搜索
for i in range(numCourses):
if not invalid and not visited[i]:
dfs(i)
if invalid:
return []
# 如果沒有環,那麼就有拓撲排序
# 注意下標 0 爲棧底,因此需要將數組反序輸出
return res[::-1]
思路2:
參考官方思路:廣度優先搜索。
思路一的深度優先搜索是一種「逆向思維」:最先被放入棧中的節點是在拓撲排序中最後面的節點。也可以使用正向思維,順序地生成拓撲排序,這種方法也更加直觀。
考慮拓撲排序中最前面的節點,該節點一定不會有任何入邊,也就是它沒有任何的先修課程要求。當將一個節點加入答案中後,就可以移除它的所有出邊,代表着它的相鄰節點少了一門先修課程的要求。如果某個相鄰節點變成了「沒有任何入邊的節點」,那麼就代表着這門課可以開始學習了。按照這樣的流程,不斷地將沒有入邊的節點加入答案,直到答案中包含所有的節點(得到了一種拓撲排序)或者不存在沒有入邊的節點(圖中包含環)。
上面的想法類似於廣度優先搜索,因此可以將廣度優先搜索的流程與拓撲排序的求解聯繫起來。
算法步驟:
使用一個隊列來進行廣度優先搜索。初始時,所有入度爲 的節點都被放入隊列中,它們就是可以作爲拓撲排序最前面的節點,並且它們之間的相對順序是無關緊要的。
在廣度優先搜索的每一步中,取出隊首的節點 :
- 將 放入答案中;
- 移除 的所有出邊,也就是將 的所有相鄰節點的入度減少 。如果某個相鄰節點 的入度變爲 ,那麼就將 放入隊列中。
在廣度優先搜索的過程結束後。如果答案中包含了這 個節點,那麼就找到了一種拓撲排序,否則說明圖中存在環,也就不存在拓撲排序了。
參考圖示:
複雜度分析:
時間複雜度: ,其中 爲課程數, 爲先修課程的要求數。這其實就是對圖進行廣度優先搜索的時間複雜度。
空間複雜度: 。題目中是以列表形式給出的先修課程關係,爲了對圖進行廣度優先搜索,需要存儲成鄰接表的形式,空間複雜度爲 。在廣度優先搜索的過程中,需要最多 的隊列空間(迭代)進行廣度優先搜索,並且還需要若干個 的空間存儲節點入度、最終答案等。
C++
class Solution {
private:
vector<vector<int>> edges; // 存儲有向圖
vector<int> indeg; // 存儲每個節點的入度
vector<int> res; // 存儲答案
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
edges.resize(numCourses); // 初始化
indeg.resize(numCourses);
for( auto& info : prerequisites){
edges[info[1]].push_back(info[0]); // 創建圖
++indeg[info[0]]; // 入度加1
}
queue<int> q_node; // 節點隊列
// 將所有入度爲0的節點放入隊列中
for(int i = 0; i < numCourses; ++i){
if(indeg[i]==0) q_node.push(i);
}
// BFS遍歷查找
while(!q_node.empty()){
int u = q_node.front(); // 隊首取出一個元素
q_node.pop();
res.push_back(u); // 放入答案
for(auto v : edges[u]){
--indeg[v]; // 入度減一
// 如果相鄰節點 v 的入度爲 0,就可以選 v 對應的課程了
if(indeg[v] == 0) q_node.push(v);
}
}
if(res.size() != numCourses) return {}; // 有環
return res;
}
};
Python:
class Solution:
def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
edges = collections.defaultdict(list) # 存儲有向圖
indeg = [0] * numCourses # 存儲每個節點的入度
res = [] # 存儲答案
for info in prerequisites:
edges[info[1]].append(info[0]) # 創建有向圖
indeg[info[0]] += 1 # 入度加1
# 將所有入度爲 0 的節點放入隊列中
q = collections.deque([u for u in range(numCourses) if indeg[u] == 0])
while q:
u = q.popleft() # 從隊首取出一個節點
res.append(u) # 放入答案中
for v in edges[u]:
indeg[v] -= 1
# 如果相鄰節點 v 的入度爲 0,就可以選 v 對應的課程了
if indeg[v] == 0:
q.append(v)
if len(res) != numCourses: return [] # 有環
return res