编程之美-翻烙饼问题

翻烙饼问题

前言

翻烙饼问题是非常经典的问题,星期五的晚上,一帮同事在希格玛大厦附近的“硬盘酒吧”多喝了几杯。程序员多喝了几杯之后谈什么呢?自然是算法问题。有个同事说:
“我以前在餐馆打工,顾客经常点非常多的烙饼。店里的饼大小不一,我习惯在到达顾客饭桌前,把一摞饼按照大小次序摆好——小的在上面,大的在下面。由于我一只手托着盘子,只好用另一只手,一次抓住最上面的几块饼,把它们上下颠倒个个儿,反复几次之后,这摞烙饼就排好序了。

我后来想,这实际上是个有趣的排序问题:假设有n块大小不一的烙饼,那最少要翻几次,才能达到最后大小有序的结果呢?

抽象化

对于这所有烙饼,进行抽象化处理,概括为一系列互不相同的整数,而且在计算最优翻转数的过程中,没人在意最大的饼是比次大的饼大了多少,所以为了简化问题方便计算(依据编程之美书中的优化算法,这样会有助于计算一种搜索的翻转下限),我们可以将n个烙饼采用一个一维n个元素的整数数组表示,其中该数组是数字1~n的一个任意排列。

解法

  • 贪心解法-最常规的解法
    每次在还没定位的所有烙饼中找寻最大烙饼,先翻转到顶部,未定位部分末端,然后将未定位部分集体翻转,实现其中的最大元素位于头部,紧挨着已定位部分,然后将最大元素纳入已经定位的部分。对于n个元素只需要2*(n-1)步翻转即可完成,复杂度为O(n)。代码如下:
    static int[] arr;
    /** * 成员变量数组arr代表所有烙饼信息,第0个元素是烙饼底部,最后元素的烙饼顶部,要求最后实现有底到顶从大到小的排列 * 该函数是为了计算翻烙饼过程中的最少操作数,定义每次翻烙饼只能从顶部到中间某个饼一次翻转, * @param arr2 烙饼信息信息数组 * @return 翻烙饼过程中的最少操作数 */
    private static int getMinSortNum(int[] arr) {
        int counter = 0;
        int mid;
        for(int i=0; i<arr.length; i++) {
            mid = i;
            for(int j=i+1; j<arr.length; j++) {
                if(arr[j] > arr[mid])
                    mid = j;
            }
            if(mid != i) {
                reverse(mid);
                reverse(i);
                counter += 2;
            }
        }
        return counter;
    }

    /** * 实现烙饼数组中某个位置到末尾集体翻转 * @param left 翻转的最左端index */
    private static void reverse(int left) {
        int right = arr.length - 1;
        while(left < right) {
            int temp = arr[left];
            arr[left] = arr[right];
            arr[right] = temp;
            left ++;
            right --;
        }
    }

  • 编程之美中的算法

其实编程之美中也并没有给出让人眼前一亮的解法,它的大致思路就是对于这一摞n个饼,我是个机器人,每次都从顶部到底部一个一个的试一下,试完一种方法我将它还原,重新试下一种做法,那岂不是要试无数次?前面提到的贪心算法就约束了你一次试验的所有翻转操作数的最大值,这个最大数意味着,翻转次数要是超过了它试验就停下别浪费时间了,这肯定不是最优解。可还是要实验很多次,算法中设计了优化的减枝函数,让搜索的效率更高效,但通过这样木讷的方法,的确可以非常有效的找到最优解,所以中间过程也就不必在意了。
通过阅读书籍和参考了两位前辈的博客
编程之美》一摞烙饼问题详解与纠错
《编程之美》读书笔记(二):烙饼的排序问题(Java实现)

下面给出我实现后的Java代码,代码在参考文章2中基础上修改,优化了部分。


import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public class CakeTuneProblem {  

    public static int min;//记录所需要翻转的最少次数 
    public static int estimateMin;//记录所需要翻转的最少次数 
    public static int minNum;//记录所需要翻转的最少次数 
    public static int[] cakeArray;//抽象化的大小不一的饼的数组序列,左边第0个位置是顶部,最右边是底部饼,保存的是饼的大小数值 
    public static int[] resultArray;//毫无用处的数组引用,只是在最后方便你查看这颗搜索树的状态,随时把它替换为更优的tempArray 
    public static int[] tempArray;//这是一个记录翻转操作过程中每次是从第几个饼翻转的,记录的是所有翻转操作的翻转数组的下标值
    public static int count = 0;   //记录总共搜索了多少次,search函数执行的次数,也是这颗整体搜索树的规模大小
    public static List<Integer> templist, finalList; //优化后直接用list来保存,不用受固定长度的约束

    /** * 这里的最理想情况是如果两个饼大小只要相差1不管顺序或是逆序都认为他们不需要再翻转了 * 而挤挨的两个饼如果相差大于1那么就肯定需要一次翻转, * 总装实际需要翻转次数只多不少 * @param * @return */
    public static int lowBound(){  
        int reduce;  
        int m = 0;  
        for(int i = 0; i < cakeArray.length-1; i++){  
            reduce = cakeArray[i] - cakeArray[i+1];  
            if(reduce == 1 || reduce == -1){  
            }  
            else{  
                m ++;  
            }  
        }  
        return m;  
    }   


    /** * 将cakeArray里的数组从顶部第0个到第index个饼全部翻转 * @param index */
    public static void reverse(int index){  
        int i = 0;  
        int j = index;  
        int temp;  
        while(i < j){  
            temp = cakeArray[i];  
            cakeArray[i] = cakeArray[j];  
            cakeArray[j] = temp;  
            i++;  
            j--;  
        }  
    }  

    /** * 判断一摞饼是否已经有序 * @param * @return */
    public static boolean isSorted(){  
        for(int i = 1; i < cakeArray.length; i++){  
            if(cakeArray[i-1] > cakeArray[i]){  
                return false;  
            }  
        }  
        return true;  
    }  

    /** * 应该是所有搜索的主函数,函数将递归调用自己 * @param depth 现在已经翻转了多少次了 */
    public static void search(int depth){  
        count++;  //这是用来记录搜索次数的变量
        estimateMin = lowBound();  //用来估算最理想情况下这摞饼需要翻转的最小次数, 

        //减支函数,depth因为递归不断在叠加,所以每次动态估算的需要翻转最佳次数也会变化,
        //如果此刻的depth加上此刻改变后的饼数组估算的翻转最小次数已经大于min了,就没有继续递归的必要了
        if((depth + estimateMin) >= min) return;  

        //如果饼数组已经有序了
        if(isSorted()){  
            //min是全局最小翻转数,只要找到一个比它小的次数,就动态更新为此时的depth
            if(min > depth){  
                min = depth;  
                finalList = new ArrayList<Integer>(templist);
// resultArray = Arrays.copyOf(tempArray, tempArray.length); 
// System.out.println("当前最少翻转次数cur_min = " + min); 
            }  
            return;  
        }  

        //这就是类似于枚举做法的从顶到底实验这一次翻转是翻到第i号饼
        for(int i = 1; i < cakeArray.length; i++){  
            if(depth != 0 && tempArray[depth] == i){  
                continue;  
            }  
            reverse(i);  
            templist.add(i);
// tempArray[depth+1] = i; 
            search(depth+1);  
            reverse(i); 
            templist.remove(templist.size()-1);
        }  
    } 

    /** * 很多人看编程之美里包括我也是,这main函数初看,这些操作好晦涩难懂啊,请先将下面涉及的静态变量的说明看下, * 主函数主要是先初始化静态饼信息数组cakeArray,以及中间搜索树数组tempArray, * 编程之美书中还初始化了resultArray在我看来是毫无必要的,毕竟resultArray完全没参与到算法中,只记录下结果而言,所以我把这部分删了 * tempArray长度比min大1,是因为在search方法里tempArray[]数组记录的是操作的下标值,然而i却每次都跳过第0个也就是顶部元素, * 因为翻转单个饼毫无意义,所以tempArray[0]默认一直是0; * 然后由search(0)直接进入搜索树中,这里的depth=0,是指你的第0步操作,代表翻转了0次 * @param args */
    public static void main(String[] args) {  
// cakeArray = createRandomArr(10); //生成10个乱序排列的饼数组
        cakeArray = new int[] {4, 2, 1, 3}; //初始化一摞饼,4在顶部,3在底部
        min = 2 * cakeArray.length - 2;  //已知的最优操作数就是左边大小
        tempArray = new int[cakeArray.length * 2 - 1];  //搜索树中间计算数组,比min小1是非常有必要的,如果相等会报错
        templist = new ArrayList<Integer>();
        search(0);  

        System.out.println(Arrays.toString(resultArray));
        System.out.println(Arrays.toString(tempArray));
        System.out.println(templist);
        System.out.println(finalList);
        System.out.println("\n最终最少翻转次数final_min = " + min);  
        System.out.println("\nTotal Run Times: " + count);  
    }  

    /** * 产生n个饼的任意顺序排列的函数,饼大小在1~n之间 * @param n 输入饼数量 * @return 返回饼数组对象引用 */
    static int[] createRandomArr(int n) {
        int[] arr = new int[n];
        List<Integer> list = new ArrayList<Integer>();
        int k = 1;
        while(k <= n)
            list.add(k ++);
        Collections.shuffle(list);
        arr = new int[list.size()];
        for(int i=0; i<list.size(); i++)
            arr[i] = list.get(i);
        return arr;
    }

}  

说明和优化

  • 记录中间所有翻转操作数的tempArray和resultArray显得非常笨拙,受固定长度约束,不如直接采用边长的ArrayList,方便简洁,在代码里也体现出来了。
  • 之前看我引用的第一篇博文博主说reverse()前后用两边没有必要,在这里强调下,reverse(i)自己在递归前后分别调用两次是非常有必要的,为什么?你可以想象下你的cakeArray数组每次递归到发现这次搜索失败了的时候,原来cakeArray数组已经改变了,而你的for循环从1到n必须保持每次每次开始时候cakeArray一致,所以必须有一个操作将上一步翻转给恢复回原状。
  • 就好比我用tempList在添加了i以后在末尾还得继续删除掉i,因为我的tempList全局变量就一个,所以必须时刻保持时刻能回复。
  • 仔细的读者会发现我的finalList全文除了声明外只出现了一次,但使用的很讲究,因为我的tempList时刻都需要能保持回复原装,如果中间搜索到一个较优策略,我需要把tempList复制到finalList去,所以这里不是单纯的把tempList的引用附给finalList,因为tempList最终一定会变成0元素(结合上一点来看);
    原文作者:rebornyp
    原文地址: https://blog.csdn.net/rebornyp/article/details/78879870
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞