65.9K
CodeProject 正在变化。 阅读更多。
Home

解决爱因斯坦谜题的机器方法(修订版)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (34投票s)

2003 年 12 月 14 日

CPOL

8分钟阅读

viewsIcon

136294

downloadIcon

1390

本文介绍了一种使用暴力搜索快速解决爱因斯坦谜题的方法。

引言

我在 Manfred Becker 的文章 Einstein's riddle solved in C++ 中发现了这个有趣的谜题。于是我急忙运行了他的代码,想快速得到答案。然而,屏幕上几分钟都没有出现解决方案,我不得不退出,然后回到源代码中寻找线索。我发现了一个时间表,上面写着在 700MHz Pentium III 上需要 2 天 19 小时 24 分钟!

虽然我理解 Becker 的解决方案只是为了展示面向对象的设计,但我认为对于我们这些没耐心的人来说,有一个快速的解决方案会更好。

为了方便参考和内容的完整性,这里附上谜题原文

爱因斯坦的谜题

  • 一排有五栋房子,每栋房子颜色都不同。
  • 每栋房子里住着一个不同国籍的人。
  • 这五位主人喝一种特定的饮料,抽一种特定的香烟品牌,养一种特定的宠物。
  • 所有主人都不拥有相同的宠物,不抽相同的香烟品牌,也不喝相同的饮料。

问题是:谁养了鱼?

爱因斯坦的线索

  1. 英国人住在红房子里。
  2. 瑞典人养狗作为宠物。
  3. 丹麦人喝茶。
  4. 绿房子在白房子正左边。
  5. 绿房子主人喝咖啡。
  6. 抽 Pall Mall 的人养鸟。
  7. 黄房子主人抽 Dunhill。
  8. 住在中间房子里的人喝牛奶。
  9. 挪威人住在第一栋房子里。
  10. 抽 Blends 的人住在养猫的人旁边。
  11. 养马的人住在抽 Dunhill 的人旁边。
  12. 抽 BlueMaster 的主人喝啤酒。
  13. 德国人抽 Prince。
  14. 挪威人住在蓝房子旁边。
  15. 抽 Blends 的人有一个喝水的邻居。

类设计考量

爱因斯坦谈论了房子和人。很自然地,我们需要一个房子类 (CHouse) 和一个人类 (CPerson),就像 Becker 那样。然而,如果你更深入地思考这些类之间的关系,你会发现它们是一对一的关系。人们经常谈论房主。那么为什么我们不只为他们设置一个类呢?当然,我们可以将一个人视为房子的所有者,同时也将一个人视为房子里的一个属性 :)。

enum Problem_Constants
{ 
    COUNT_HOUSES = 5, 
    COUNT_CATEGORIES = 5, 
    COUNT_HINTS = 15,
    BITS_CatID = 3,
    COUNT_EXTENDED_CatID = 1 << BITS_CatID  //  COUNT_EXTENDED_CatID = 8
};

// Property Category ID
enum CatID 
{
    COLOR,
    NATIONALITY,
    BAVERAGE,
    CIGAR,
    PET
};

// Property Identifications
enum PropID
{
    BLUE       = (0 << BITS_CatID) | COLOR,    
    GREEN      = (1 << BITS_CatID) | COLOR,         
    RED        = (2 << BITS_CatID) | COLOR,      
    WHITE      = (3 << BITS_CatID) | COLOR,     
    YELLOW     = (4 << BITS_CatID) | COLOR, 

    BRIT       = (0 << BITS_CatID) | NATIONALITY,   
    DANE       = (1 << BITS_CatID) | NATIONALITY,        
    GERMAN     = (2 << BITS_CatID) | NATIONALITY,   
    NORWEGIAN  = (3 << BITS_CatID) | NATIONALITY, 
    SWEDE      = (4 << BITS_CatID) | NATIONALITY,  

    BEER       = (0 << BITS_CatID) | BAVERAGE,
    COFFEE     = (1 << BITS_CatID) | BAVERAGE, 
    MILK       = (2 << BITS_CatID) | BAVERAGE,
    TEA        = (3 << BITS_CatID) | BAVERAGE,
    WATER      = (4 << BITS_CatID) | BAVERAGE,

    BLENDS     = (0 << BITS_CatID) | CIGAR,
    BLUEMASTER = (1 << BITS_CatID) | CIGAR,
    DUNHILL    = (2 << BITS_CatID) | CIGAR,
    PALLMALL   = (3 << BITS_CatID) | CIGAR,
    PRINCE     = (4 << BITS_CatID) | CIGAR,

    BIRD       = (0 << BITS_CatID) | PET,
    CAT        = (1 << BITS_CatID) | PET,
    DOG        = (2 << BITS_CatID) | PET,
    FISH       = (3 << BITS_CatID) | PET,
    HORSE      = (4 << BITS_CatID) | PET
};

enum HouseID
{
    HOUSE0, HOUSE1, HOUSE2, HOUSE3, HOUSE4
};

class CHouse
{
private:
    enum 
    {    
        // EMPTY is a flag to indicate a null property of any category.
        // Its value must not be equal to any
        // real property (any element of PropID).
        EMPTY  = (0 << BITS_CatID) | PET + 1
    };

    PropID m_property[COUNT_CATEGORIES];

// constructor
public:
    CHouse() 
    {
        for (int i = 0; i < COUNT_CATEGORIES; i++)
            m_property[i] = EMPTY;
    }

// operations
public:
    int IsEmpty(CatID cid)          { return m_property[cid] == EMPTY; }
    int IsEmpty(PropID pid)         { return IsEmpty(GetCatID(pid)); }
    int GetPropIndex(CatID cid)     { return GetIndex(m_property[cid]); }
    void SetProperty(PropID pid)    { m_property[GetCatID(pid)] = pid; }
    void ClearProperty(PropID pid)  { m_property[GetCatID(pid)] = EMPTY;}

private:
    CatID GetCatID(PropID pid)  
      { return (CatID) (pid & (COUNT_EXTENDED_CatID - 1)); }
    int GetIndex(PropID pid)    { return pid >> BITS_CatID; }
};

很简单,对吧?CHouse 只有一个数据成员 m_property,它是一个按类别 (CatID) 索引的数组,用于存放 5 种属性。CHouse 对象是自包含的,它不知道它的邻居。我们都知道狗是宠物,啤酒是饮料。为了满足用户的需求,CHouse 通过其成员函数 GetCatID(PropID pid)PropID 的组合定义来理解这种类型关系。

让我们转向问题的另一部分,即线索,它们描述了属性和所有者之间的关联。我们需要一个类来表示这些线索。我敢打赌,你一眼就会注意到这些线索差异很大。显然,我们不会得到一个像 CHouse 这样简单的统一类。好吧,我们将通过抽象和派生来解决这些差异。

那么,所有线索的共同点是什么呢?它们都至少涉及一个属性和一个可能的该属性的所有者。因此,我们将线索命名为一个基类 CHint,并使其有两个数据成员 m_pid0m_pNewOwner,分别代表属性和可能的拥有者。CHint 对象还需要访问任何 CHouse 对象,因为其中的属性 (m_pid0) 可以被任何符合线索的房主拥有。我们在 CHint 中以静态数据成员的形式放置了一个包含 5 个 CHouse 对象的数组。在我们继续讨论 CHint 的成员函数之前,请看它的蓝图。

class CHint
{
protected:
    const PropID m_pid0;  // a property in the hint
    CHouse* m_pNewOwner0; // ptr to a would-be owner
                          // of the property to meet the hint
    CHouse* m_pNext;      // the next house to be checked and filled

    static CHouse house[COUNT_HOUSES]; // array of the houses
private:
    // array of pointers to house owners indexed by PropID
    static CHouse* propowner[COUNT_CATEGORIES*COUNT_EXTENDED_CatID];
    static int count_solutions; // count of solutions found

// constructor
protected:
    CHint(PropID pid0) : m_pid0(pid0) {}

public:
    // starting from house specified by m_pNext, seek and assign the 
    // first eligible houseowner(s) with properties. 
    //
    // return value:
    //       0 : failed    
    // nonzero : successful
    //
    virtual int AssignPropToNextEligibles() = 0;    

    // undo AssignPropToNextEligibles
    virtual void RemovePropFromPrevEligibles() = 0;
    // reset the seek to start position
    virtual void ResetSeekPosition() = 0;
    // print out a solution
    static  void PrintOut();

// helpers
protected:
    void RemoveProperty();   // remove property from new owner
    int AssignPropertySetNextPos(CHouse* p0, CHouse* pNext);

// property register operations
protected:
    static CHouse* GetPropertyOwner(PropID pid) 
                     { return propowner[pid]; }
    static void RegisterOwnership(PropID pid, CHouse* pOwner) 
    {    
        propowner[pid] = pOwner;
    }

// counter
public:
    static int GetSolutionCount()  { return count_solutions; };
private:
    static void IncSolutionCount() { count_solutions++; };

// helper
    // verify a solution and check for errors
    static CHouse* VerifyCurrentSolution();
};

它有三个纯虚函数,使得 CHint 成为一个抽象类。正如 CHouse 对象不知道它的邻居一样,CHint 对象也不应该知道其他 CHint 对象,以保持事情的简单性。但是 CHint 对象应该有一个方法来查找所有符合该线索要求的房子。它不必一次性返回所有可能的房主列表。相反,我们希望它一次找到一个房主 (m_pNewOwner) 来匹配其属性 (m_pid0),并按顺序进行。这可以提供更好的整体效率,因为内存流量更少。这三个虚函数正好满足了这一需求。

  • AssignPropToNextEligibles() 查找下一个匹配的房主,并将属性分配给他。数据成员 m_pNext 用于让 AssignPropToNextEligibles() 知道在哪里恢复其工作。
  • RemovePropFromPrevEligibles() 用于撤销 AssignPropToNextEligibles() 所做的属性分配,以便我们可以再次调用 AssignPropToNextEligibles() 来查找另一个房主。
  • 最后一个虚函数 ResetSeekPosition() 是一个用于将搜索位置重置到起点的接口。

这些纯虚函数可以被视为占位符和规范。任何派生自 CHint 的类仍然必须实现这三个函数。

CHint 中包含了静态数据成员 propowner 及其函数 GetPropertyOwnerRegisterOwnership,以便于访问任何属性的拥有者。该数据成员和这两个函数是属性分类账的实现,因此我们可以避免每次需要知道谁拥有特定属性时都进行线性搜索。

静态函数 VerifyCurrentSolutionPrintOut 分别用于验证和打印解决方案,从概念上讲,它们与 CHint 类关系不大。我将它们包含在这里只是因为它们也需要访问这五栋房子。如果我在 OOP 设计方面是个原教旨主义者,我可能会把它们踢出去,而是提供只读服务。

现在我们可以从 CHint 派生类了。线索 8 和 9 只涉及基类已提供的单个属性。但它们每个都有一个已知的房子编号。所以我们这样定义类:

// class for any hint of Known house number
//
class CKnownHouseHint : public CHint
{
private:
    CHouse* const m_pKnownHouse;

public:
    CKnownHouseHint(HouseID i, PropID pid) : 
        CHint(pid), m_pKnownHouse(&house[i]) {}

private:
    virtual int AssignPropToNextEligibles();    
    virtual void ResetSeekPosition()           { m_pNext = m_pKnownHouse; }
    virtual void RemovePropFromPrevEligibles() { RemoveProperty(); }
};

其余的线索涉及两个属性,它们可以由不同的拥有者拥有。我们通过派生 CMultiPropertiesHint 类来反映这种差异。

// class for any hint that involves Multiple properties
//
class CMultiPropertiesHint : public CHint
{
protected:
    // a property in the hint
    const PropID m_pid1;
    // ptr to a would-be owner of the property to meet the hint
    CHouse* m_pNewOwner1;

// implementations
protected:
    virtual void RemovePropFromPrevEligibles();

// constructor
protected:
    CMultiPropertiesHint(PropID pid0, 
      PropID pid1) : CHint(pid0), m_pid1(pid1) { }

// helpers
protected:
    void ResetSeekPositionToFirstHouse() { m_pNext = &house[HOUSE0]; }
    int AssignPropertiesSetNextPos(CHouse* p0, CHouse* p1, CHouse* pNext);
};

CMultiPropertiesHint 仍然是进一步派生的抽象基类,尽管它提供了一些有用的服务。

有些线索只涉及单个房主,例如线索 1。CSingleHouseHintCMultiPropertiesHint 派生,用于处理这些线索。

// class for the any hint that only involves a Single houseowner
//
class CSingleHouseHint : public CMultiPropertiesHint
{
// constructor
public:
    CSingleHouseHint(PropID pid0, 
      PropID pid1) : CMultiPropertiesHint(pid0, pid1) { }

// implementations
private:
    virtual int AssignPropToNextEligibles();    
    virtual void ResetSeekPosition() { ResetSeekPositionToFirstHouse(); }
};

有些线索涉及两个房主,例如线索 15。CNeighborsHintCMultiPropertiesHint 派生,用于处理这些线索。

// class for any hint that involves
// 2 Neighbors without a directional restriction
//
class CNeighborsHint : public CMultiPropertiesHint
{
private:
    int m_NextReverse; // are the properties to be filled in reverse order?

// constructor
public:
    CNeighborsHint(PropID pid0, 
      PropID pid1) : CMultiPropertiesHint(pid0, pid1) { }

// implementations
private:
    virtual int AssignPropToNextEligibles();    
    virtual void ResetSeekPosition()        
    { 
        ResetSeekPositionToFirstHouse(); 
        m_NextReverse = 0;
    }
};

线索 4 也提到了两栋房子。但这两栋房子必须是顺序排列的。我们只有这一个实例,但我们必须为它派生一个类来完成 CHint 类家族。

// class for any hint that involves
// 2 Neighbors Ordered by direction
//
class COrderedNeighborsHint : public CMultiPropertiesHint
{
// constructor
public:
    COrderedNeighborsHint(PropID pid0, 
      PropID pid1) : CMultiPropertiesHint(pid0, pid1) {}

// implementations
private:
    virtual int AssignPropToNextEligibles();    
    virtual void ResetSeekPosition() { ResetSeekPositionToFirstHouse(); }
};

我在这里省略了这些类的实现。你可以在源代码文件中找到它们。

既然谜题现在可以用一组 CHint 对象和一组 CHouse 对象来很好地表示,让我们通过定义一个 CSolver 类来开始破解这个谜题。

// class for finding the solutions
//
class CSolver
{
private:
    static CHint* hint[COUNT_HINTS]; // array of pointers to CHint objects
    static int count_nodes; // count of nodes searched

// operations
public:
    static void DoTheWork(); // search driver

// helpers
private:
    static void Search(CHint** ppCHint); // search engine
    static void PrintEinsteinRiddleDescription();
    static void IncNodeCount() { count_nodes++; };
    static int GetNodeCount() { return count_nodes; };
};

CSolver 在一个指向 CHint 对象的指针静态数组中封装了这 15 条线索。在实现文件中,我们只需一步创建这些对象并初始化数组。

// Array of pointers to CHint objects, i.e. a list of hints.
//
CHint* CSolver::hint[COUNT_HINTS] = 
{
    new CSingleHouseHint(BRIT, RED),                // Hint 1
    new CSingleHouseHint(SWEDE, DOG),               // Hint 2
    new CSingleHouseHint(DANE, TEA),                // Hint 3
    new COrderedNeighborsHint(GREEN, WHITE),        // Hint 4
    new CSingleHouseHint(GREEN, COFFEE),            // Hint 5
    new CSingleHouseHint(PALLMALL, BIRD),           // Hint 6
    new CSingleHouseHint(YELLOW, DUNHILL),          // Hint 7
    new CKnownHouseHint(HOUSE2, MILK),              // Hint 8
    new CKnownHouseHint(HOUSE0, NORWEGIAN),         // Hint 9
    new CNeighborsHint(BLENDS, CAT),                // Hint 10
    new CNeighborsHint(HORSE, DUNHILL),             // Hint 11
    new CSingleHouseHint(BLUEMASTER, BEER),         // Hint 12
    new CSingleHouseHint(GERMAN, PRINCE),           // Hint 13
    new CNeighborsHint(NORWEGIAN, BLUE),            // Hint 14
    new CNeighborsHint(BLENDS, WATER)               // Hint 15
};

现在 CSolver 可以使用指向 CHint 对象的指针进行工作,并忽略这些对象之间的差异。CSolver 有一个单一的公共接口函数 DoTheWork(),它将被 main() 函数调用。DoTheWork() 将实际工作委托给 Search 函数。

暴力搜索

下面是穷举 Search 函数的一个简化版本。

void CSolver::Search(int index_of_hint)
{
    
    hint[index_of_hint]->ResetSeekPosition(); // reset to start position.

    // seek each set of eligibles & assign
    // properties to them if needed.
    while (hint[index_of_hint]->AssignPropToNextEligibles()) 
    {

        if (index_of_hint != 15 - 1) // if the hint is not the last one.
            Search(index_of_hint + 1); // search with next hint.
        else  // all hints have been met.
            CHint::PrintOut(); // a solution has been found.
        // undo the assign operations.
        hint[index_of_hint]->RemovePropFromPrevEligibles();
    }
}

正如你所看到的,Search 是一个递归函数。它尝试为当前线索找到匹配项。如果找到了匹配项,它会调用自身来处理下一条线索,前提是当前线索不是最后一条,此时它会打印出解决方案,并继续搜索。如果它完成了查找所有可能的匹配项,或者未能找到任何匹配项,它就会将控制权返回给上一条线索。

DoTheWork() 将以 index_of_hint = 0 开始第一次调用。

性能

在以前的版本中,当使用标准的 clock() 进行计时时,它在 1.6GHz P4 上需要 0.016 秒,在 166MHz Pentium MMX 上需要 0.056 秒。

当前版本使用高分辨率性能计数器来获得更精确的结果。然而,不同的运行时会话仍然显示出相当大的时间差异。一点点的性能分析表明,程序超过 80% 的时间花在打印解决方案上!我不得不对绘图代码进行一些优化,以减少差异。经过这些调整后,在 2.60GHz P4 上只需 0.0014 秒。

无论如何,这比我预期的要快得多。现在我认为一台旧的 8086 也可以轻松破解这个谜题。可惜我找不到一台。

更新

如果我们能通过玩这个程序来更深入地了解这个谜题,那将很有趣。这里有一些。

线索 15 被证明是冗余的,因为在将其从线索列表中注释掉后,会产生完全相同的解决方案。我们也可以去掉线索 5,让那些喜欢纸笔计算的人更有挑战性。

版本 1.04 (2003/12/23) 中的一些调整使得在列表中添加或删除线索更加容易。

版本 1.05 (2013/10/25) 增加了 UNICODE 支持;同时发布了 C# 移植源代码,并附带更新的 C++ 代码。

 

© . All rights reserved.