连载10:一摞饼的排序(《编程之美》第1.3节)

来源:互联网 发布:java lisp 编辑:程序博客网 时间:2024/05/17 01:35

问题

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

如图所示,两次翻转可以将最大的饼弄到最下面去。

 

分析

    此问题是连载8中提到的“离散型优化问题”,我们可以采用启发式搜索来解决。
    启发式搜索搜索部分代码如下:

            node = new Anode(array);
            open.Push(node);
            while (open.Count > 0)
            {
                node = open.Pop();//取出当前最优节点
                if (node.H == 0) break;//找到解
                if (close.Contains(node)) continue;//排除重复
                for (int i = 1; i < node.ItemCount; i++)
                {
                    var child = new ANode(node, i);//发展后代
                    if (!Exists(child)) open.Push(child);
                }
                close.TryAdd(node);
            }

其中:
open 类型是BinHeap<ANode>,前文提到的二叉堆;
ANode 类用来表示解,其中一个数组表示饼的顺序,一个整形变量保存了是
如何从“父节点”翻转来的;
ANode 中2 个变量G,H 体现启发搜索的重要信息:
启发函数F = G (实际翻转次数)+ H(估计还需翻转次数)
open 中,节点按照F 保持有序。
close 类型是Dictionary<ANode>用来保存已经找到过的状态,避免重复。

        bool Exists(ANode node) //用来检查当前发展的后代:
        {
            if (close.ContainsKey(node)) return true;//是否已经有过
            int i = open.IndexOf(node);
            if (i >= 0)
            {
                var old = open[i];
                if (old.G > node.G)//是否有更好的翻转办法到达这个状态
                {
                    old.Renew(node);
                    open.TryPromote(i + 1);//更新节点位置
                }
                return true;
            }
            return false;
        }

 

实验

我们来看《编程之美》提到的例子:3, 2, 1, 6, 5, 4, 9, 8, 7, 0

1: 3,2,1,6,5,4,9,8,7,0 Turn Over at: 4 Heuristic = 3

2: 5,6,1,2,3,4,9,8,7,0 Turn Over at: 8 Heuristic = 3

3: 7,8,9,4,3,2,1,6,5,0 Turn Over at: 6 Heuristic = 3

4: 1,2,3,4,9,8,7,6,5,0 Turn Over at: 8 Heuristic = 2

5: 5,6,7,8,9,4,3,2,1,0 Turn Over at: 4 Heuristic = 1

6: 9,8,7,6,5,4,3,2,1,0 Turn Over at: 9 Heuristic = 1

Result: 0,1,2,3,4,5,6,7,8,9

    程序总共只花费42 次迭代找到了最优解,相比172126 次有不小的改进,由此说明,启发函数估计越准确,求解速度越快。
    对于比较长的如:6, 15, 3, 9, 0, 1, 7, 5, 11,16, 2, 12, 8, 13, 4, 10, 14也能在几秒得到最优解:
    9,7,13,15,2,11,4,9,7,16,14,6,10,5,11,12,8
 

有关启发函数


    如前面实验分析,启发函数是否估计得好,对求解速度有着至关重要的影响。我们先来看2 个启发函数:

H1:主要反映数组中相邻数字是否相差超过1,每次翻转可以让应该相邻的两个数
组挨在一起,因此拿它做估计是比较合理的,可以保证F 值≤真实翻转次数,从而保证
最优解。

            int iv = 0;
            for (int i = 1; i < mArray.Length; i++)
                if (Math.Abs(mArray[i] - mArray[i - 1]) > 1) iv++;
            if (iv == 0 && mArray[0] != 0) iv++;
            return iv;

H2:在“线性代数”中我们曾学到过排列的“逆序数”,反映了它与标准排列的差
异,因此我们可以这样来估计:

            int iv1 = 0, iv2 = 0;
            for (int i = 0; i < mArray.Length; i++)
                for (int j = 0; j < mArray.Length; j++)
                {
                    if (i == j) continue;
                    if (mArray[j] < mArray[i])

                   {
                        if (j < i) iv1++;
                        else iv2++;
                    }
                }
            return Math.Min(iv1, iv2 + 1);

    有了两个启发函数,可以做实验了,我们先在节点的比较函数里面写到: 

            int order = F.CompareTo(other.F); //F = G + H1
            return order;

    运行程序,测试n=7 时的全排列,总共花费345 秒。我们再对比较函数稍做改动:

            int order = F.CompareTo(other.F); //F = G + H1
            if (order == 0) order = F2.CompareTo(other.F1); //F = G + H2
            return order;

    再运行程序,此时总共花费时间为151 秒。当然,如果我们还能够找到更好的启发函数,或许还可以提高求解速度。

作者:Silver  原文链接:http://gpww.blog.163.com/blog/static/118268164200997105639786/

其他文章:
连载1:卡特兰数(Catalan)
连载2:序列 ABAB对应字符串集合
连载3:最长公共子序列
连载4:计算字符串的相似度
连载5:寻找符合条件的整数
连载6:数组循环位移
连载7:天平秤球
连载8:动态有序集合——挑战红黑树
连载9:寻找最大的k 个数