回溯法搜索问题的优化--以单词拆分问题为例

1. 单词拆分问题

在这里插入图片描述

2. 确定解题思路

经过思考,发现这是一个搜索问题。每次从s开头截取一个子串s’,如果s’在字典中,则继续搜索s剩余的部分;如果没在就不需要往下搜了。
根据这个思路,可得代码如下:

class Solution {
    boolean able = false;
    public void search(String s, Set<String> set){
        if(able) return;
        if(s.length() == 0){
            able = true;
            return;
        }

        for(int i = 1; i <= s.length(); i++){
            if(set.contains(s.substring(0, i))){
                search(s.substring(i), set);
            }
        }
    }
    public boolean wordBreak(String s, List<String> wordDict) {
        Set<String> set = new HashSet<>();
        for(String word: wordDict){
            set.add(word);
        }
        search(s, set);
        return able;
    }
}

但是提交的时候,超时了。超时的测试例是:

s = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"
wordDict = ["a","aa","aaa","aaaa","aaaaa","aaaaaa","aaaaaaa","aaaaaaaa","aaaaaaaaa","aaaaaaaaaa"]

3. 第一次修改

因为看到都是a,最后一个b,因为这个题的题意是只要找到任意一个满足的分割方法即可,我的朴素的想法就是先每次截取最长的子串,然后再依次递减,这样可以快速搜到。因此代码改为了:

int length = Math.min(max, s.length());
for(int i = length; i > 0; i--){//每次尽可能从最长的单词开始截取
    if(set.contains(s.substring(0, i))){
        search(s.substring(i), set, max);
    }
}

但是还是超时了。

4. 第2次修改

发现其实完全没必要每次截取都从最长的子串截到最短,因为你截的子串最终是要在字典中进行查找的,那么子串的长度应该被限制在[max, min]之中,max,min分别表示字典中最长单词的长度和最短单词的长度。于是代码进一步改为:

int length = Math.min(max, s.length());
for(int i = length; i >= min; i--){//i是截取的单词长度,从最长开始到最短
    if(set.contains(s.substring(0, i))){
        search(s.substring(i), set, max, min);
    }
}

仍然超时了。

5. 第3次修改

画了一个搜索树:
在这里插入图片描述
然后才发现,最关键的问题是重复的子问题太多了,那就将已经搜索过发现不能分开的子串存起来,以便于再次搜到这里时,直接返回此路不通的标识,而不再继续搜:

class Solution {
    boolean able = false;
    public void search(String s, Set<String> set, int max, int min, Set<String> not){
        if(able) return;
        if(s.length() == 0){
            able = true;
            return;
        }
        int length = Math.min(max, s.length());
        for(int i = length; i >= min; i--){//i是截取的单词长度,从最长开始到最短
            if(set.contains(s.substring(0, i))){
                if(!not.contains(s.substring(i)))//只有当子串可能分时才进行搜索
                    search(s.substring(i), set, max, min, not);
            }
        }
        if(!able){//如果搜完该子树,able没变为真,说明该子树不能分
            not.add(s);
        }
    }
    public boolean wordBreak(String s, List<String> wordDict) {
        Set<String> set = new HashSet<>();
        Set<String> not = new HashSet<>();

        int max = 0, min = 0x7fffffff;
        for(String word: wordDict){
            set.add(word);
            if(word.length() > max) max = word.length();
            if(word.length() < min) min = word.length();
        }
        search(s, set, max, min, not);
        return able;
    }
}

这一次终于通过了,并且效果还挺好,超过了94%的人。

6. 总结

前几次提交的代码,都是朴素的想法去提高效率,而没有想这其中的道理:

  1. 第一次修改背后隐含的思想。首先可以知道这棵搜索树是不平衡的,也就是有的叶子离根很近,深度浅,很快就能搜到,有的叶子离根很远,深度深,要搜很多次。因此第一次修改其实是将搜索从先搜最深处的然后再搜浅处的,这样可以节省一定的时间,但是有一个问题,就是如果s不能被分,那最终还是要搜完整棵树,这样改只能在平均时间上加快搜索成功的时候的时间,注意是平均时间,因为假如符合条件的叶子就在深处,那么自然还是从深处开始的好,但是我们不知道的情况下,就只能默认每一个叶子出现的概率是一致的。
  2. 第二次修改背后隐含的思想。按照上面的方法,我们的搜索树的宽度为s.length(),因为每个结点可以截取的子串长度为s.length(),…,1,对于每一个子串都要进行一次搜索,树的深度为s.length()/1。而修改后,树的宽度变成了(max - min + 1),树的深度为 ⌈ s . l e n g t h ( ) / m i n ⌉ \lceil s.length()/min\rceil s.length()/min,这样其实一定程度上减小了搜索树的大小,也就是减小了搜索空间。
  3. 但是上面的修改都没有触及到核心问题,就是子问题重复,导致了需要做大量的重复性工作。因此第3次修改,使用了一个not集合,保存了那些已经搜索过但是此路不通(即该子树不能够被分割)的子树(子串)。这样的话,再遇到相同的子问题,就可以不用再对其进行搜索了,从而大大减少了工作量,这样就高效通过了。

综上,以后遇到类似的搜索问题时,要养成先分析搜索空间的习惯,然后查看搜索类型,是只搜到一个正确的叶子就返回还是要全树搜,再然后查看是否有重复子问题,有的话就要想办法进行优化(一般都是空间换时间)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值