Android程序员会遇到的算法(part 3 深度优先搜索-回溯backtracking)

上一遍文章我们过了一次广度优先算法,算是比较好理解的,因为模式比较固定,使用队列再进行while() 循环,既可以满足大部分时候的需求。这一次我们来开始学习/复习一下我们的深度优先算法。深度优先算法其实在很多地方都可以应用到,其实在我的看法,只要搜索集合相对固定,并且使用到递归的算法都可以算是深度优先。而且在学习完回溯算法的很多题目之后,大家也可以更直观的体验到,很多时候回溯也是暴力搜索的一种程序上的实现而已。所以,

1.回溯

2.深度优先

3.暴力搜索

这三种算法,名字虽然不同,但是都在某些情况下是有很大的共同成分的。大家在看完题目之后可以好好感受一下。

那么进入正题


1.理解递归

如果是计算机专业的同学可能可以忽略这一小节。

[图片上传失败...(image-1a1a5f-1518421713974)]

其实递归的方法和一般的方法有什么区别呢?答案是完全没有,递归的方法和一般的方法完全没有区别。一个标准的方法/函数,都是需要在方法/函数栈里面进行调用和返回的。举个栗子。

static void a(){
    b();
}

static void b(){
    c();
}


static void c(){
    System.out.println("methods")
}


public static void main(String[] args){
    a();
}


下面这段代码在方法栈中的执行过程如下两图所示。

方法执行
方法结束

如上图所示,所有的方法在执行结束之后都会返回,return,这个return,代表的是return到该方法的调用者,也就是执行该方法的方法内,也就是上一层中。

理解了这个,递归也就很好理解,同样是使用方法/函数栈,只不过是调用的方法是相同的方法而已。

2.理解回溯

理解回溯,我们先从一个例题来看一下。

我们有一个集合/列表,含有若干整数(不含有重复),例如:{1,2,3}。 求该集合的全排列。
{1,2,3}
{1,3,2}
{2,1,3}
{2,3,1}
{3,1,2}
{3,2,1}

从直观的感觉来说,第一眼遇到这个题目,我们可以这么去抽象的构思:

我们按照步骤/状态来抽象的话,我们每一刻都有一个可用集合一个答案集合。每一步都需要从可用集合里面抽取一个元素加入到答案集合。在答案集合满了(或者是可用集合空了)的时候,代表我们获取了一个答案,这时需要向后,往可用集合内部退回元素。再重新做抽取的步骤,往答案集合中放置元素。

1开头的集合答案

可以看出来,我们每次获取答案都要向上退后一步,回到之前的状态,选取不同的元素放入结果集合。这个过程其实就是我们之前所讲的回溯。至于怎么样遍历集合,根据题目的要求我们可以有不同的策略,一般的回溯算法都是涉及列表的遍历,for循环足矣。

我们来看看代码

public List<List> getPermutation(List<Integer> list){
    List result = new ArrayList<>();
    permutateHelper(result,new ArrayList<>(), list, new HashSet<Integer>());
    return result;
}

private void permutateHelper(List result, List<Integer> temp,List<Integer> list, HashSet<Integer> visited){
    //如果temp,temp答案集合满了,我们加入到最终的结果集合内。
    if(temp.size() == list.size()){
        result.add(new ArrayList(temp));
    }
    else{
        //直接使用for循环进行对原集合的遍历
        for(int i = 0; i< list.size();i++){
            //如果没有visit过,进行递归
            if(!visited.contains(list.get(i))){
                int current = list.get(i);
                temp.add(current);
                visited.add(current);
                //进入下一层递归
                permutateHelper(result,temp,list,visited);
                visited.remove(current);
                //这里需要直接remove掉最后一个元素,因为我们的全部的下一层递归已经结束,所以可以把该层的数字删掉,进入for循环的下一个遍历的开始了。这里这个remove的动作就是我们所谓的“回溯”
                temp.remove(temp.size()-1);      
            }
        }
    }

}

从以上代码我们可以看出,回溯算法其实就是普通的递归,但是加上了对集合遍历(for 循环)的过程,大家可以体会一下一个小小的区别。假如在以上的代码中,我们的temp不删除最后一个元素,改成这样:

private void permutateHelper(List result, List<Integer> temp,List<Integer> list, HashSet<Integer> visited){
    if(temp.size() == list.size()){
        result.add(new ArrayList(temp));
    }
    else{
        for(int i = 0; i< list.size();i++){
            if(!visited.contains(list.get(i))){
                int current = list.get(i);
                temp.add(current);
                visited.add(current);
                //进入下一层递归,不删除最后一个元素,每次都创建一个新的temp列表
                permutateHelper(result,new ArrayList<>(temp),list,visited);
                visited.remove(current);
            }
        }
    }

}

简单的一行的修改,最后的答案也是对的(先不说这个修改浪费了多少空间),可是却完全的改变了我们要的回溯的本质,没有向前退的这个动作,这个程序就变成了单纯的递归,暴力搜索了。

关于排列组合的题目,还有更加难的,比如集合中有重复元素怎么办,如果不只是求全排列,而是求子集呢?

有兴趣的同学可以看看leetcode上的题目:

1.Permutation II
2.Subset

相信大家对所谓的回溯已经有点理解了。我们再来一个难一点点的题目。

3. 电话键盘

例题来自leetcode的一道关于电话键盘的题目

Screen Shot 2018-02-12 at 3.14.47 PM.png

这个题目就是说,在手机上按几个数字键,对应可能产生的所有可能的字母的集合。比如在手机上按23,就是从{a,b,c}和{d,e,f}中各取一个放入答案集合中。

这题和上一题的区别是,搜索集合不再是一个固定的大集合了,而是若干的小集合,每个数字对应一个小集合,满足搜索结果的答案的标准也不一样,不再是以集合的元素数量为标准,而是以我们的输入数字的数量为标准。

我们直接看代码

public List<String> letterCombinations(String digits) {
        if (digits == null || digits.length() == 0) {
            return new ArrayList<>();
        }

                ///先初始化手机按键的数字和字母的关系
        String[] one = { "" };
        String[] two = { "a", "b", "c" };
        String[] three = { "d", "e", "f" };
        String[] four = { "g", "h", "i" };
        String[] five = { "j", "k", "l" };
        String[] six = { "m", "n", "o" };
        String[] seven = { "p", "q", "r", "s" };
        String[] eight = { "t", "u", "v" };
        String[] nine = { "w", "x", "y", "z" };
        String[] zero = { "" };

        HashMap<Integer, String[]> map = new HashMap<>();
        map.put(0, zero);
        map.put(1, one);
        map.put(2, two);
        map.put(3, three);
        map.put(4, four);
        map.put(5, five);
        map.put(6, six);
        map.put(7, seven);
        map.put(8, eight);
        map.put(9, nine);

        ArrayList<String> result = new ArrayList<>();
        int[] allNum = new int[digits.length()];
        for (int i = 0; i < digits.length(); i++) {
            allNum[i] = Integer.parseInt(digits.substring(i, i + 1));
        }
        phoneNumberHelper(result, new StringBuilder(), 0, allNum, map);
        return result;

    }

    private void phoneNumberHelper(ArrayList<String> result, StringBuilder current, int index, int[] allNum,
            HashMap<Integer, String[]> com) {
                //如果找到一个排列,加入答案中
        if (index == allNum.length) {
            result.add(current.toString());
            return;
        } else {
                        //使用for循环,遍历当前该数字的字母集合
            String[] possibilities = com.get(allNum[index]);
            for (int i = 0; i < possibilities.length; i++) {
                phoneNumberHelper(result, current.append(possibilities[i]), index + 1, allNum, com);
                                //一定要把StringBuilder的最后一位删除掉。
                current.deleteCharAt(current.length()-1);
            }
        }
    }


可以看出,回溯的题目并不难,在理解了排列组合题目之后,理解这个就简单一些了。我们对比一下和排列组合题目的不同。

搜索集合在每一个状态都不一样,排列组合在每个步骤都是相同的搜索集合,电话按键根据按下的数字不一样,对应的字母不一样。

对于回溯算法的精髓,大家通过对两个题目的练习可以发现,就在于那个remove的动作,假如上面这个算法我改成这样,不使用StringBuilder,而直接使用Sting.


public List<String> letterCombinations(String digits) {
        if (digits == null || digits.length() == 0) {
            return new ArrayList<>();
        }
        String[] one = { "" };
        String[] two = { "a", "b", "c" };
        String[] three = { "d", "e", "f" };
        String[] four = { "g", "h", "i" };
        String[] five = { "j", "k", "l" };
        String[] six = { "m", "n", "o" };
        String[] seven = { "p", "q", "r", "s" };
        String[] eight = { "t", "u", "v" };
        String[] nine = { "w", "x", "y", "z" };
        String[] zero = { "" };

        HashMap<Integer, String[]> map = new HashMap<>();
        map.put(0, zero);
        map.put(1, one);
        map.put(2, two);
        map.put(3, three);
        map.put(4, four);
        map.put(5, five);
        map.put(6, six);
        map.put(7, seven);
        map.put(8, eight);
        map.put(9, nine);

        ArrayList<String> result = new ArrayList<>();
        int[] allNum = new int[digits.length()];
        for (int i = 0; i < digits.length(); i++) {
            allNum[i] = Integer.parseInt(digits.substring(i, i + 1));
        }
        phoneNumberHelper(result, "", 0, allNum, map);
        return result;

    }

    private void phoneNumberHelper(ArrayList<String> result, String current, int index, int[] allNum,
            HashMap<Integer, String[]> com) {
        if (index == allNum.length) {
            result.add(current);
            return;
        } else {
            String[] possibilities = com.get(allNum[index]);
            for (int i = 0; i < possibilities.length; i++) {
                //不使用StringBuilder,直接使用String连接一个String,这个做法其实和new String()是一样的。创建了一个新的String,
                phoneNumberHelper(result, current + possibilities[i], index + 1, allNum, com);
            }
        }
    }


算法大部分都是相同的,但是直接直接使用String连接一个String,这个做法其实和new String()是一样的。创建了一个新的String,和我们的排列组合里面的new ArrayList<>(temp)这段代码一样,虽然最终结果没错,但是却丧失了回溯算法的本质和优势,浪费了空间。一行之差。

4.N皇后问题

这一篇的最后一个问题,当然非N皇后问题莫属,题目太经典,我就不浪费篇幅再写一次算法了。我这次就着重分析一下这个怎么把这个问题抽象成回溯问题。怎么把这个问题模型化,通俗点说,怎么把这个问题和排列组合问题找到相同的地方,方便大家理解。

我们每一次在棋盘上放棋子,其实就是从原集合,往答案集合中加入元素的一个动作。和排列组合问题不同的是,往答案集合里面加入元素这个动作不是每次都是合法的,而排列组合是无论怎么加都对。

举个栗子

u=4191624954,1499040036&fm=27&gp=0.jpg

在放置第五个棋子的时候,我们在遍历的过程中需要判断第五个可以合法的放置在哪个位置,第一行?不行,因为第一个棋子也在第一行。第二第三第四同理。都通不过我们的检查。所以在for循环中要对之前已经放置的棋子做比较,看看能不能放置到我们想放置的位置,如果不行,那么我们需要回退到上一层。

所以N皇后问题最后的难点就在于,怎么表示放置棋子的位置?怎么做所谓的合法检查?这些大家可以自己思考一下再用java实现一下。

当我以前在复习N皇后问题的时候,我有那么一刹那和排列组合问题做了个比较,顿时恍然大悟。原来原理是相同的。

这次的回溯算法的分析就到此位置,下一次我会做一个更全面的深度优先的算法分析。

祝大家新年快乐!!

u=3534538955,3983762870&fm=27&gp=0.jpg
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容