You have a lock in front of you with 4 circular wheels. Each wheel has 10 slots
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
The wheels can rotate freely and wrap around: for example, we can
'9'
'0'
'0'
'9'
The lock initially starts at '0000'
, a string representing the state of the 4 wheels.
You are given a list of deadends
dead ends, meaning if the lock displays any of these codes, the wheels of the lock will stop turning and you will be unable to open it.
Given a target
representing the value of the wheels that will unlock the lock, return the minimum total number of turns required to open the lock, or -1 if it is impossible.
Example 1:
Input: deadends = ["0201","0101","0102","1212","2002"], target = "0202" Output: 6 Explanation: A sequence of valid moves would be "0000" -> "1000" -> "1100" -> "1200" -> "1201" -> "1202" -> "0202". Note that a sequence like "0000" -> "0001" -> "0002" -> "0102" -> "0202" would be invalid, because the wheels of the lock become stuck after the display becomes the dead end "0102".
Example 2:
Input: deadends = ["8888"], target = "0009" Output: 1 Explanation: We can turn the last wheel in reverse to move from "0000" -> "0009".
Example 3:
Input: deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"], target = "8888" Output: -1 Explanation: We can't reach the target without getting stuck.
Example 4:
Input: deadends = ["0000"], target = "8888" Output: -1
Note:
- The length of
deadends
will be in the range[1, 500]
. target
will not be in the listdeadends
.- Every string in
deadends
and the stringtarget
will be a string of 4 digits from the 10,000 possibilities'0000'
to'9999'
.
翻译:
给你一个包含四个环形转轮的密码锁,每个转轮上10个格子对应从'0'到'9'的10个数字。这些转轮可以向两个方向自由转动,也就是可以从'0'转到'9',也可以从'9'转到'0'。每一步你只可以转一个转轮一格。这个密码锁还有一些死锁状态(输入的deadends),当四个转轮的密码转入这些状态时这个锁就再也打不开了。假设这个锁的初始状态是"0000",给你一个目标状态target,请问你至少需要多少步才能够打开?如果不可能打开该密码锁,则输出-1。
例如,输入死锁状态deadends为数组["0201", "0101", "0102", "1212", "2002"],目标状态target为"0202",则至少需要6步从最初的"0000"转到"0202"。其中一个可能的步骤序列为"0000" -> "1000" -> "1100" -> "1200" -> "1201" -> "1202" -> "0202"。
注意序列"0000" -> "0001" -> "0002" -> "0102" -> "0202"尽管更短只有4步,但由于中间有个死锁状态"0102",因此这是一个无效的开锁步骤。
分析:
密码锁由四个转轮上的数字组成密码锁的状态,同时我们可以把转动密码锁看成是密码锁状态的改变。一般而言如果一个问题是某事物状态的改变,那么我们可以考虑把问题转换成图搜索的问题。
解决图的问题的第一步就是找出问题对应的图(Graph)。由于图是顶点和边的集合,因此找图的关键是找出图的顶点和边。对于这个问题,密码锁的每一个状态都对应着图上的一个顶点。比如状态"0000"是一个顶点,"0001"是另一个顶点。
如果转动某个转轮一次可以让密码锁从一个状态转移到另一个状态,那么这两个状态之间有一条边相连。例如在状态"0000"分别向上或者向下转动四个转轮中的一个,可以得到如下8个状态"0001"、"0009"、"0010"、"0090"、"0100"、"0900"、"1000"和"9000"。那么图中顶点"0000"就有8条边分别和这8个状态对应的顶点相连。
解决图的问题的第二步是决定用什么顺序来遍历图。通常有两种不同方法遍历图,广度优先搜索和深度优先搜索。由于题目要求的是找出顶点"0000"到目标状态对应的顶点的最短路径,那么采用广度优先搜索算法更优。这是因为广度优先搜索是从源点开始首先达到所有距离源点为1的顶点,接着轮到达所有距离源点为2的所有顶点。根据广度优先搜索从源点到达某一顶点,那么一定是途径从源点到达该结点的最短路径。
遍历密码锁对应的图时还要注意的是要避开死锁状态对应的顶点,因此一达到这些顶点之后就不能继续往下搜索了。
下面是基于广度优先搜索的Java代码:
public int openLock(String[] deadends, String target) {
Set<String> dead = new HashSet<>(Arrays.asList(deadends));
Set<String> visited = new HashSet<>();
String init = "0000";
if (dead.contains(init) || dead.contains(target)) {
return -1;
}
Queue<String> queue1 = new LinkedList<>();
Queue<String> queue2 = new LinkedList<>();
queue1.offer(init);
int step = 0;
while (!queue1.isEmpty()) {
String cur = queue1.poll();
if (cur.equals(target)) {
return step;
}
List<String> nexts = getNexts(cur);
for (String string : nexts) {
if (!dead.contains(string) && !visited.contains(string)) {
queue2.offer(string);
visited.add(string);
}
}
if (queue1.isEmpty()) {
step += 1;
queue1 = queue2;
queue2 = new LinkedList<>();
}
}
return -1;
}
上述代码用两个队列实现宽度优先遍历。队列queue1里存的是需要转动n次达到的顶点,queue2是和queue1里的顶点相连同时还没有遍历到的顶点。当queue1的里顶点都删掉之后,接着遍历需要转动n+1次到达的顶点,也就是queue2里的顶点,此时变量steps加1。
LinkedList中add和offer的区别:
offer属于 interface Deque,add 属于 interface Collection。
当队列为空时候,使用add方法会报错,而offer方法会返回false。
作为List使用时,一般采用add / get方法来 压入/获取对象。
作为Queue使用时,才会采用 offer/poll/take等方法作为链表对象时,offer等方法相对来说没有什么意义这些方法是用于支持队列应用的。
下面是辅助函数getNexts的代码,它的作用是根据密码锁的转动规则得到与某一状态相连的8个状态:
public static List<String> getNexts(String cur){
List<String> nexts = new LinkedList<>();
for (int i = 0; i < cur.length(); i ++) {
char ch = cur.charAt(i);
char newCh = (ch == '9') ? '0' : (char) (ch + 1);
StringBuffer sb = new StringBuffer(cur);
sb.setCharAt(i, newCh);
nexts.add(sb.toString());
newCh = (ch == '0') ? '9' : (char) (ch - 1);
sb = new StringBuffer(cur);
sb.setCharAt(i, newCh);
nexts.add(sb.toString());
}
return nexts;
}
双向广度优先搜索
这个问题是单个源点("0000")单个目标(输入的target)的广度优先搜索。我们可以想象当我们到达目标节点的时候,我们同时还遍历了求解树中位于同一层的其他节点。尽管在那一层中我们只需要遍历一个节点,我们却实际上遍历很多不必要的节点,因此单向搜索是存在优化空间的。
我们可以把单项搜索改为双向搜索,也就是既从源点出发向着目标搜索,也从目标出发向着源点搜索。如果两个方向搜索最终能够在中间某个位置相遇,那么表明存在从源点到目标的路径。
我们只需要在单向搜索的代码上稍微作些改动,就能实现双向搜索,如下所示:
public static int openLockGood(String[] deadends, String target) {
Set<String> dead = new HashSet<>(Arrays.asList(deadends));
Set<String> visited = new HashSet<>();
String init = "0000";
int step = 0;
if (dead.contains(init) || dead.contains(target)) {
return -1;
}
if (init.equals(target)) {
return step;
}
Set<String> set1 = new HashSet<>();
set1.add(init);
Set<String> set2 = new HashSet<>();
set2.add(target);
while (!set1.isEmpty() && !set2.isEmpty()) {
if (set1.size() > set2.size()) {
Set<String> temp = set1;
set1 = set2;
set2 = temp;
}
Set<String> set3 = new HashSet<>();
for (String cur: set1) {
List<String> nexts = getNexts(cur);
for (String string : nexts) {
if (set2.contains(string)) {
return step+1;
}
if (!dead.contains(string) && !visited.contains(string)) {
visited.add(string);
set3.add(string);
}
}
}
step += 1;
set1 = set3;
}
return -1;
}
其中一个改动是保存节点的数据结构由队列Queue改成了集合HashSet。这是由于我们需要判断我们某一方向搜索的节点是不是位于另一个方向搜索的集合之中(也就是判断两个方向的搜索是否相遇)。HashSet能够更高效地完成这一要求。另外,我们只要做到遍历求解树的一层之后遍历下一层。同一层的遍历顺序并不重要,因此广度优先搜索算法本身也不是一定要用先进先出的队列实现。
我们一共用了三个集合。集合set1和set2保存两个方向当前遍历层的节点。我们总是优先遍历set1和set2中节点数目较少的那个方向的节点(通过交换set1和set2确保set1中的节点数目总是小于set2的)。集合set3保存的set1的节点的下一层节点。