• 基于Monte Carlo方法的2048 A.I.


    2048 A.I. 在 stackoverflow 上有个讨论:http://stackoverflow.com/questions/22342854/what-is-the-optimal-algorithm-for-the-game-2048

    得票最高的回答是基于 Min-Max-Tree + alpha beta 剪枝,启发函数的设计很优秀。

    其实也可以不用设计启发函数就写出 A.I. 的,我用的方法是围棋 A.I. 领域的经典算法——Monte Carlo 局面评估 + UCT 搜索。

    算法的介绍见我几年前写的一篇博文:http://www.cnblogs.com/qswang/archive/2011/08/28/2360489.html

    简而言之就两点:

    1. 通过随机游戏评估给定局面的得分;
    2. 从博弈树的父节点往下选择子节点时,综合考虑子节点的历史得分与尝试次数。

    针对2048游戏,我对算法做了一个改动——把 Minx-Max-Tree 改为 Random-Max-Tree,因为增加数字是随机的,而不是理性的博弈方,所以猜想 Min-Max-Tree 容易倾向过分保守的博弈策略,而不敢追求更大的成果。

    UCT搜索的代码:

    Orientation UctPlayer::NextMove(const FullBoard& full_board) const {
      int mc_count = 0;
      while (mc_count < kMonteCarloGameCount) {
        FullBoard current_node;
        Orientation orientation = MaxUcbMove(full_board);
        current_node.Copy(full_board);
        current_node.PlayMovingMove(orientation);
        NewProfit(&current_node, &mc_count);
      }
    
      return BestChild(full_board);
    }

    NewProfit函数用于更新该节点到某叶子节点的记录,是递归实现的:

    float UctPlayer::NewProfit(board::FullBoard *node,
        int* mc_count) const {
      float result;
      HashKey hash_key = node->ZobristHash();
      auto iterator = transposition_table_.find(hash_key);
      if (iterator == transposition_table_.end()) {
        FullBoard copied_node;
        copied_node.Copy(*node);
        MonteCarloGame game(move(copied_node));
    
        if (!HasGameEnded(*node)) game.Run();
    
        result = GetProfit(game.GetFullBoard());
        ++(*mc_count);
        NodeRecord node_record(1, result);
        transposition_table_.insert(make_pair(hash_key, node_record));
      } else {
        NodeRecord *node_record = &(iterator->second);
        int visited_times = node_record->VisitedTimes();
        if (HasGameEnded(*node)) {
          ++(*mc_count);
          result = node_record->AverageProfit();
        } else {
          AddingNumberRandomlyPlayer player;
          AddingNumberMove move = player.NextMove(*node);
          node->PlayAddingNumberMove(move);
          Orientation max_ucb_move = MaxUcbMove(*node);
          node->PlayMovingMove(max_ucb_move);
          result = NewProfit(node, mc_count);
          float previous_profit = node_record->AverageProfit();
          float average_profit = (previous_profit * visited_times + result) /
              (visited_times + 1);
          node_record->SetAverageProfit(average_profit);
        }
    
        node_record->SetVisitedTimes(visited_times + 1);
      }
    
      return result;
    }

    起初用结局的最大数字作为得分,后来发现当跑到512后,Monte Carlo棋局的结果并不会出现更大的数字,各个节点变得没有区别。于是作了改进,把移动次数作为得分,大为改善。

    整个程序的设计分为 board、player、game 三大模块,board 负责棋盘逻辑,player 负责移动或增加数字的逻辑,game把board和player连起来。

    Game类的声明如下:

    class Game {
    public:
      typedef std::unique_ptr<player::AddingNumberPlayer>
      AddingNumberPlayerUniquePtr;
      typedef std::unique_ptr<player::MovingPlayer> MovingPlayerUniquePtr;
    
      Game(Game &&game) = default;
    
      virtual ~Game();
    
      const board::FullBoard& GetFullBoard() const {
        return full_board_;
      }
    
      void Run();
    
    protected:
      Game(board::FullBoard &&full_board,
          AddingNumberPlayerUniquePtr &&adding_number_player,
          MovingPlayerUniquePtr &&moving_player);
    
      virtual void BeforeAddNumber() const {
      }
    
      virtual void BeforeMove() const {
      }
    
    private:
      board::FullBoard full_board_;
      AddingNumberPlayerUniquePtr adding_number_player_unique_ptr_;
      MovingPlayerUniquePtr moving_player_unique_ptr_;
    
      DISALLOW_COPY_AND_ASSIGN(Game);
    };

    Run函数的实现:

    void Game::Run() {
      while (!HasGameEnded(full_board_)) {
        if (full_board_.LastForce() == Force::kMoving) {
          BeforeAddNumber();
    
          AddingNumberMove
          move = adding_number_player_unique_ptr_->NextMove(full_board_);
          full_board_.PlayAddingNumberMove(move);
        } else {
          BeforeMove();
    
          Orientation orientation =
              moving_player_unique_ptr_->NextMove(full_board_);
          full_board_.PlayMovingMove(orientation);
        }
      }
    }

    这样就可以通过继承 Game 类,实现不同的构造函数,组合出不同的 Game,比如 MonteCarloGame 的构造函数:

    MonteCarloGame::MonteCarloGame(FullBoard &&full_board) :
        Game(move(full_board),
        std::move(Game::AddingNumberPlayerUniquePtr(
        new AddingNumberRandomlyPlayer)),
        std::move(Game::MovingPlayerUniquePtr(new MovingRandomlyPlayer))) {}

    一个新的2048棋局,会先放上两个数字,新棋局应该能方便地build。默认应该随机地增加两个数字,builder 类可以这么写:

    template<class G>
    class NewGameBuilder {
    public:
      NewGameBuilder();
      ~NewGameBuilder() = default;
    
      NewGameBuilder& SetLastForce(board::Force last_force);
    
      NewGameBuilder& SetAddingNumberPlayer(game::Game::AddingNumberPlayerUniquePtr
          &&initialization_player);
    
      G Build() const;
    
    private:
      game::Game::AddingNumberPlayerUniquePtr initialization_player_;
    };
    
    template<class G>
    NewGameBuilder<G>::NewGameBuilder() :
        initialization_player_(game::Game::AddingNumberPlayerUniquePtr(
        new player::AddingNumberRandomlyPlayer)) {
    }
    
    template<class G>
    NewGameBuilder<G>& NewGameBuilder<G>::SetAddingNumberPlayer(
        game::Game::AddingNumberPlayerUniquePtr &&initialization_player) {
      initialization_player_ = std::move(initialization_player);
      return *this;
    }
    
    template<class G>
    G NewGameBuilder<G>::Build() const {
      board::FullBoard full_board;
    
      for (int i = 0; i < 2; ++i) {
        board::AddingNumberMove move = initialization_player_->NextMove(full_board);
        full_board.PlayAddingNumberMove(move);
      }
    
      return G(std::move(full_board));
    }

    很久以前,高效的 C++ 代码不提倡在函数中 return 静态分配内存的对象,现在有了右值引用就方便多了。

    main 函数:

    int main() {
      InitLogConfig();
      AutoGame game = NewGameBuilder<AutoGame>().Build();
      game.Run();
    }

    ./fool2048:

    这个A.I.的移动不像基于人为设置启发函数的A.I.那么有规则,不会把最大的数字固定在角落,但最后也能有相对不错的结果,游戏过程更具观赏性~

    项目地址:https://github.com/chncwang/fool2048

    最后发个招聘链接:http://www.kujiale.com/about/join

    我这块的工作主要是站内搜索、推荐算法等,欢迎牛人投简历到hr邮箱~

  • 相关阅读:
    Exadata存储节点的CPU限制成功了没?
    如何减少Exadata计算节点CPU的Core数量
    如何选择适合你的HTAP数据库?
    小知识:Oracle中的层次查询
    小知识:使用MOS下载Oracle介质快速参考
    小知识:Flex ASM特性对集群资源显示的影响
    javaWeb request请求乱码、response响应中文乱码一站式解决方案
    java 文件File与byte[]数组相互转换的两种方式
    pr 如何调高导出视频的清晰度?
    pr 剪辑视频之剃刀用法
  • 原文地址:https://www.cnblogs.com/qswang/p/3749685.html
Copyright © 2020-2023  润新知