问题描述
正在解决的问题是LeetCode #752 "Open the Lock"。总结:
您有一个带四个一位数轮子的组合锁,从 0
、0
、0
、0
开始。您想确定将锁定更改为显示 a
、b
、c
、d
所需的最小移动次数。在每个动作中,您可以向前或向后旋转一个轮子,例如从 0
到 1
或 0
到 9
的第一个轮子。
有“死角”组合。如果您的动作序列到达死胡同,锁就会卡住。所以你必须在不遇到死胡同的情况下到达目标组合。
例如:
deadends = ["0201","0101","0102","1212","2002"],target = "0202"
一系列有效的移动将是“0000”->“1000”->“1100”->“1200”->“1201”->“1202”->“0202”。 请注意,像 "0000" -> "0001" -> "0002" -> "0102" -> "0202" 这样的序列将是无效的, 因为当显示器变成死角“0102”后,锁的轮子卡住了。 (复制自问题描述)
我设计了一个使用队列和两个集合的广度优先搜索。一组包含死胡同,一组包含已经考虑过的组合。
如果我在从队列中弹出后将一个组合插入到“已考虑的”集合中,我的解决方案需要太多时间来执行。如果我在组合生成后立即插入,那么我的解决方案执行得更快。
为什么?我认为它们实际上是相同的!对于“接近”0
、0
、0
、0
的组合,“慢”解决方案仍然设法收敛。对于距离较远的组合,在时限内不收敛。
class Solution {
public:
int openLock(vector<string>& deadends,string target) {
// "minimum total number of turns" suggests BFS (queue)
// time complexity is O(C^N) where C is constant (number of choices for each digit,e.g. ten,or 24 if a...z) and N is the length of the combo
// other terms are N^2 and + N_deadends
// string operations are N^2: for each of the N digits,we need to construct a string of length N (itself an O(N) operation) -> O(N*N*2) (twice for +1 and -1)
int nMoves = 0;
std::queue<std::string> toConsider;
toConsider.push("0000");
std::set<std::string> considered;
std::set<std::string> de;
for (auto d : deadends) de.insert(d); // converting the deadends vector to a set improves speed significantly
while (!toConsider.empty()) {
int const nStatesInLevel = toConsider.size();
for (int i = 1; i <= nStatesInLevel; ++i) {
std::string state = toConsider.front();
toConsider.pop();
// considered.insert(state); // IF WE PUT THIS HERE INSTEAD OF BELOW,THE SOLUTION IS TOO SLOW!
if (de.find(state) != de.end()) continue; // veto if dead-end
if (state == target) return nMoves;
for (int i : {0,1,2,3}) { // one out of four wheels to turn
int const oldWheelVal = state.at(i) - '0';
for (int c : {1,-1}) { // increase or decrease wheel value
int newWheelVal = oldWheelVal + c;
if (newWheelVal == -1) newWheelVal = 9;
if (newWheelVal == 10) newWheelVal = 0;
std::string newWheel = state;
newWheel.at(i) = newWheelVal + '0';
if (considered.find(newWheel) == considered.end()) {
toConsider.push(newWheel);
considered.insert(newWheel); // we need to put this here instead of after it is popped in order to avoid "time limit exceeded".
// I think both places should effectively be the same!
}
}
}
}
nMoves++;
}
return -1;
}
};
解决方法
慢版本将允许将重复的状态添加到队列中。只有在将它们从其中拉出时才会检测到它们是重复的,但是“伤害”已经完成。由于有许多不同的路径可以到达相同的状态,这将很快使队列变得不必要地大,并填充大量重复项。这不仅需要内存成本,而且需要时间成本,因为额外的内存分配需要时间。
首先从队列中取出初始状态 0000
,并标记为已访问,然后将添加以下内容:
1000,9000,0100,0900,0010,0090,0001,0009
然后在下一次迭代中,会拉取1000
,并将以下内容添加到队列中。 0000
将被找到,但不会添加到队列中(标记为 ----
):
2000,----,1100,1900,1010,1090,1001,1009
然后 9000
将从队列中拉出,并添加以下内容:
----,8000,9100,9900,9010,9090,9001,9009
然后 0100
将从队列中拉出,并附加在后面。同样,0000
将不会被添加,但是,其他状态再次被发现没有,但尚未从队列中拉出,因此它们不会被检测为重复(用星号标记):
1100*,9100*,0200,0110,0190,0101,0109
...等等。我们走得越远,添加的重复项就越多。
更快的版本永远不会将重复的状态推送到队列中。