【问题标题】:ACM Problem: Coin-Flipping, help me identify the type of problem this isACM 问题:抛硬币,帮我确定这是什么类型的问题
【发布时间】:2010-09-19 02:22:03
【问题描述】:

我正在为即将到来的 ACM 编程比赛练习一周,但我被这个编程问题难住了。

问题如下:


你有一个拼图,由一个 4 号的方格组成。每个方格都有一个硬币;每枚硬币都显示正面 (H) 和反面 (T)。这里展示了一个这样的谜题:

HHHH
T T T T
H T H T
T T H T

任何当前显示为反面 (T) 的硬币都可以翻转为正面 (H)。然而,每当我们掷硬币时,我们也必须在同一排的正上方、下方和左侧和右侧翻转相邻的硬币。因此,如果我们翻转第二行中的第二个硬币,我们还必须翻转另外 4 个硬币,给我们这样的安排(改变的硬币以粗体显示)。

H T H H
H H H T
H H H T
T T H T

如果硬币在拼图的边缘,因此一侧或另一侧没有硬币,那么我们翻转的硬币就更少。我们不会“环绕”到另一边。例如,如果我们翻转上面排列的右下角的硬币,我们会得到:

H T H H
H H H T
H H H H
T T T H

注意:只能选择显示 (T) 尾部的硬币进行翻转。但是,无论何时我们翻转这样的硬币,相邻的硬币也会被翻转,无论它们的状态如何。

谜题的目标是让所有硬币都出现正面。虽然某些安排可能没有解决方案,但所有给出的问题都会有解决方案。我们正在寻找的答案是,对于任何给定的 4x4 硬币网格,为了使网格完全正面,翻转的最少次数是多少。

例如网格:
H T H H
T T T H
H T H T
呵呵哒

这个网格的答案是:2 次翻转。


到目前为止我做了什么:

我将我们的网格存储为布尔值的二维数组。正面 = 真,反面 = 假。 我有一个 flip(int row, int col) 方法,可以根据上述规则翻转相邻的硬币,我有一个 isSolved() 方法,可以确定是否拼图处于已解决状态(所有头)。所以我们有我们的“机制”。

我们遇到的问题是我们应该如何循环,以最少的次数深入?

【问题讨论】:

  • “熄灯”稍稍扭曲,很酷。
  • 如果您需要研究数学,这可能会有所帮助。 geocities.com/jaapsch/puzzles/lomath.htm#litonly
  • 这个问题在线吗?
  • 它可能在一些 ACM 编程竞赛过去的问题页面上,但我没有在那里找到它。这是在我们必须在 5 小时内尝试的一系列问题中给出的。

标签: algorithm


【解决方案1】:

这是经典的“熄灯”问题。实际上有一个简单的O(2^N) 蛮力解决方案,其中N 是宽度或高度,以较小者为准。

假设以下对宽度有效,因为您可以转置它。

一个观察结果是您不需要按同一个按钮两次 - 它只是取消。

关键概念只是您只需要确定是否要为第一行的每个项目按下按钮。每隔一个按钮按下由一件事唯一决定 - 所考虑按钮上方的灯是否亮起。如果您正在查看单元格(x,y),并且单元格(x,y-1) 处于打开状态,则只有一种方法可以将其关闭,即按(x,y)。从上到下遍历行,如果最后没有灯亮,你就有了解决方案。然后,您可以在所有尝试中取最小值。

【讨论】:

    【解决方案2】:

    我坐下来尝试自己解决这个问题(基于我在这个线程中收到的帮助)。我使用的是 2d 布尔数组,所以它不如使用 16 位整数和位操作的人好。

    无论如何,这是我在 Java 中的解决方案:

    import java.util.*;
    
    class Node
    {
        public boolean[][] Value;
        public Node Parent;
    
        public Node (boolean[][] value, Node parent)
        {
            this.Value = value;
            this.Parent = parent;
        }
    }
    
    
    public class CoinFlip
    {
        public static void main(String[] args)
        {
            boolean[][] startState =  {{true, false, true, true},
                                       {false, false, false, true},
                                       {true, false, true, false},
                                       {true, true, false, false}};
    
    
            List<boolean[][]> solutionPath = search(startState);
    
            System.out.println("Solution Depth: " + solutionPath.size());
            for(int i = 0; i < solutionPath.size(); i++)
            {
                System.out.println("Transition " + (i+1) + ":");
                print2DArray(solutionPath.get(i));
            }
    
        }
    
        public static List<boolean[][]> search(boolean[][] startState)
        {
            Queue<Node> Open = new LinkedList<Node>();
            Queue<Node> Closed = new LinkedList<Node>();
    
            Node StartNode = new Node(startState, null);
            Open.add(StartNode);
    
              while(!Open.isEmpty())
              {
                  Node nextState = Open.remove();
    
                  System.out.println("Considering: ");
                  print2DArray(nextState.Value);
    
                  if (isComplete(nextState.Value))
                  {
                      System.out.println("Solution Found!");
                      return constructPath(nextState);
                  }
                  else
                  {
                    List<Node> children = generateChildren(nextState);
                    Closed.add(nextState);
    
                    for(Node child : children)
                    {
                        if (!Open.contains(child))
                            Open.add(child);
                    }
                  }
    
              }
    
              return new ArrayList<boolean[][]>();
    
        }
    
        public static List<boolean[][]> constructPath(Node node)
        {
            List<boolean[][]> solutionPath = new ArrayList<boolean[][]>();
    
            while(node.Parent != null)
            {
                solutionPath.add(node.Value);
                node = node.Parent;
            }
            Collections.reverse(solutionPath);
    
            return solutionPath;
        }
    
        public static List<Node> generateChildren(Node parent)
        {
            System.out.println("Generating Children...");
            List<Node> children = new ArrayList<Node>();
    
            boolean[][] coinState = parent.Value;
    
            for(int i = 0; i < coinState.length; i++)
            {
                for(int j = 0; j < coinState[i].length; j++)
                {
                    if (!coinState[i][j])
                    {
                        boolean[][] child = arrayDeepCopy(coinState);
                        flip(child, i, j);
                        children.add(new Node(child, parent));
    
                    }
                }
            }
    
            return children;
        }
    
        public static boolean[][] arrayDeepCopy(boolean[][] original)
        {
             boolean[][] r = new boolean[original.length][original[0].length];
             for(int i=0; i < original.length; i++)
                     for (int j=0; j < original[0].length; j++)
                           r[i][j] = original[i][j];
    
             return r;
        }
    
        public static void flip(boolean[][] grid, int i, int j)
        {
            //System.out.println("Flip("+i+","+j+")");
            // if (i,j) is on the grid, and it is tails
            if ((i >= 0 && i < grid.length) && (j >= 0 && j <= grid[i].length))
            {
                // flip (i,j)
                grid[i][j] = !grid[i][j];
                // flip 1 to the right
                if (i+1 >= 0 && i+1 < grid.length) grid[i+1][j] = !grid[i+1][j];
                // flip 1 down
                if (j+1 >= 0 && j+1 < grid[i].length) grid[i][j+1] = !grid[i][j+1];
                // flip 1 to the left
                if (i-1 >= 0 && i-1 < grid.length) grid[i-1][j] = !grid[i-1][j];
                // flip 1 up
                if (j-1 >= 0 && j-1 < grid[i].length) grid[i][j-1] = !grid[i][j-1];
            }
        }
    
        public static boolean isComplete(boolean[][] coins)
        {
            boolean complete = true;
    
            for(int i = 0; i < coins.length; i++)
            {
                for(int j = 0; j < coins[i].length; j++)
                {
                    if (coins[i][j] == false) complete = false; 
                }
    
            }
            return complete;
        }
    
        public static void print2DArray(boolean[][] array) 
        {
            for (int row=0; row < array.length; row++) 
            {
                for (int col=0; col < array[row].length; col++)
                {
                    System.out.print((array[row][col] ? "H" : "T") + " ");
                }
                System.out.println();
            }
        }
    
    }
    

    【讨论】:

      【解决方案3】:

      如果您正在为 ACM 练习,我会考虑这个难题也适用于非平凡的板,比如 1000x1000。蛮力/贪婪可能仍然有效,但要小心避免指数级爆炸。

      【讨论】:

        【解决方案4】:

        好的,现在我已经正确阅读了规则,这是一个答案:)

        这是一种广度优先搜索,使用状态队列和到达那里所采取的动作。它不会尝试阻止循环,但您必须指定要尝试的最大迭代次数,因此它不能永远持续下去。

        这个实现创建了很多个字符串——一个不可变的移动链接列表在这方面会更整洁,但我现在没有时间。

        using System;
        using System.Collections.Generic;
        
        public class CoinFlip
        {
            struct Position
            {
                readonly string moves;
                readonly int state;
        
                public Position(string moves, int state)
                {
                    this.moves = moves;
                    this.state = state;
                }
        
                public string Moves { get { return moves; } } 
                public int State { get { return state; } }
        
                public IEnumerable<Position> GetNextPositions()
                {
                    for (int move = 0; move < 16; move++)
                    {
                        if ((state & (1 << move)) == 0)
                        {                    
                            continue; // Not allowed - it's already heads
                        }
                        int newState = state ^ MoveTransitions[move];
                        yield return new Position(moves + (char)(move+'A'), newState);
                    }
                }
            }
        
            // All ints could really be ushorts, but ints are easier 
            // to work with
            static readonly int[] MoveTransitions = CalculateMoveTransitions();
        
            static int[] CalculateMoveTransitions()
            {
                int[] ret = new int[16];
                for (int i=0; i < 16; i++)
                {
                    int row = i / 4;
                    int col = i % 4;
                    ret[i] = PositionToBit(row, col) +
                        PositionToBit(row-1, col) +
                        PositionToBit(row+1, col) +
                        PositionToBit(row, col-1) +
                        PositionToBit(row, col+1);
                }
                return ret;
            }
        
            static int PositionToBit(int row, int col)
            {
                if (row < 0 || row > 3 || col < 0 || col > 3)
                {
                    return 0;
                }
                return 1 << (row * 4 + col);
            }
        
            static void Main(string[] args)
            {
                int initial = 0;
                foreach (char c in args[0])
                {
                    initial += 1 << (c-'A');
                }
        
                int maxDepth = int.Parse(args[1]);
        
                Queue<Position> queue = new Queue<Position>();
                queue.Enqueue(new Position("", initial));
        
                while (queue.Count != 0)
                {
                    Position current = queue.Dequeue();
                    if (current.State == 0)
                    {
                        Console.WriteLine("Found solution in {0} moves: {1}",
                                          current.Moves.Length, current.Moves);
                        return;
                    }
                    if (current.Moves.Length == maxDepth)
                    {
                        continue;
                    }
                    // Shame Queue<T> doesn't have EnqueueRange :(
                    foreach (Position nextPosition in current.GetNextPositions())
                    {
                        queue.Enqueue(nextPosition);
                    }
                }
                Console.WriteLine("No solutions");
            }
        }
        

        【讨论】:

          【解决方案5】:

          编辑:我没有注意到你不能使用硬币作为主要动作,除非它显示出反面。这确实使秩序变得重要。我将把这个答案留在这里,但也考虑再写一个。

          这里没有伪代码,但请想一想:你能想象自己掷硬币两次吗?会有什么效果?

          或者,写下一些任意的板(字面意思,写下来)。设置一些真实世界的硬币,并选择两个任意的,X 和 Y。做一个“X 翻转”,然后一个“Y 翻转”,然后另一个“X 翻转”。写下结果。现在将电路板重置为初始版本,然后进行“Y 翻转”。比较结果,想想发生了什么。多试几次,有时 X 和 Y 靠得很近,有时不行。对你的结论充满信心。

          这种思路应该会引导您找到一种方法来确定一组有限的可能解决方案。您可以相当轻松地测试所有这些。

          希望这个提示不要太明显 - 我会密切关注这个问题,看看您是否需要更多帮助。这是一个很好的谜题。

          至于递归:您可以使用递归。就个人而言,我不会在这种情况下。

          编辑:实际上,再三考虑我可能会使用递归。它可以让生活变得更简单。


          好吧,也许这还不够明显。让我们标记硬币 A-P,如下所示:

          ABCD
          EFGH
          IJKL
          MNOP
          

          翻转 F 将始终涉及以下硬币更改状态:BEFGJ。

          翻转 J 将始终涉及以下硬币更改状态:FIJKN。

          如果你掷硬币两次会发生什么?无论发生什么其他翻转,这两个翻转都会相互抵消。

          也就是说,先翻F再翻J和翻J再翻F是一样的。先翻F再翻J再翻F和先翻J是一样的。

          所以任何解决方案都不是真正的“翻转 A 然后 F 然后 J”的路径 - 它是“翻转 ;不要翻转 ”。 (不幸的是,“翻转”这个词被用于翻转主要硬币和改变特定移动状态的辅助硬币,但没关系 - 希望我的意思很清楚。)

          每个硬币都将用作或不用作主要移动,0 或 1。有 16 个硬币,因此有 2^16 种可能性。所以 0 可能代表“什么都不做”; 1 可能代表“只是 A”; 2 可能代表“只是 B”; 3“A和B”等

          测试每个组合。如果(不知何故)有多个解决方案,请计算每个解决方案中的位数以找到最少的位数。

          实现提示:“当前状态”也可以表示为 16 位数字。使用特定硬币作为主要移动将始终将当前状态与固定数字(对于该硬币)进行异或。这使得计算任何特定动作组合的效果变得非常容易。


          好的,这是 C# 中的解决方案。它显示了它找到的每个解决方案需要多少移动,但它不会跟踪那些移动是什么,或者移动的最少数量是多少。这是一个 SMOP :)

          输入是一个列表,其中列出了哪些硬币开始出现反面 - 因此对于问题中的示例,您将使用“BEFGJLOP”参数启动程序。代码:

          using System;
          
          public class CoinFlip
          {
              // All ints could really be ushorts, but ints are easier 
              // to work with
              static readonly int[] MoveTransitions = CalculateMoveTransitions();
          
              static int[] CalculateMoveTransitions()
              {
                  int[] ret = new int[16];
                  for (int i=0; i < 16; i++)
                  {
                      int row = i / 4;
                      int col = i % 4;
                      ret[i] = PositionToBit(row, col) +
                          PositionToBit(row-1, col) +
                          PositionToBit(row+1, col) +
                          PositionToBit(row, col-1) +
                          PositionToBit(row, col+1);
                  }
                  return ret;
              }
          
              static int PositionToBit(int row, int col)
              {
                  if (row < 0 || row > 3 || col < 0 || col > 3)
                  {
                      // Makes edge detection easier
                      return 0;
                  }
                  return 1 << (row * 4 + col);
              }
          
              static void Main(string[] args)
              {
                  int initial = 0;
                  foreach (char c in args[0])
                  {
                      initial += 1 << (c-'A');
                  }
                  Console.WriteLine("Initial = {0}", initial);
                  ChangeState(initial, 0, 0);
              }
          
              static void ChangeState(int current, int nextCoin, int currentFlips)
              {
                  // Reached the end. Success?
                  if (nextCoin == 16)
                  {
                      if (current == 0)
                      {
                          // More work required if we want to display the solution :)
                          Console.WriteLine("Found solution with {0} flips", currentFlips);
                      }
                  }
                  else
                  {
                      // Don't flip this coin
                      ChangeState(current, nextCoin+1, currentFlips);
                      // Or do...
                      ChangeState(current ^ MoveTransitions[nextCoin], nextCoin+1, currentFlips+1);
                  }
              }
          }
          

          【讨论】:

          • 好吧,我在我的问题中声明,我们考虑过掷硬币一次,检查它是否“已解决”,如果不是......将其翻转回来。但是,当您考虑需要进行几层翻转才能达到解决状态时,您会感到困惑。
          • 好吧,假设你掷硬币 X,然后掷硬币 Y,然后再掷硬币 X。将结果与仅抛硬币 Y 而从不接触 X 进行比较......
          • 对,但是我们会将所有相邻的硬币也翻转到 Y,所以即使我们已经将 X 的相邻硬币恢复到除了 Y 之外的原始状态,我们已经影响了周围的硬币Y.对吗?
          • 我觉得我说得还不够清楚 - 但 300 个字符可能还不够。我将编辑我的答案。
          • 好吧,我会更明目张胆的:)
          【解决方案6】:

          正如其他人已经提到的,我建议进行广度优先搜索。

          这里的大秘密是拥有多个游戏板副本。不要想到“董事会”。

          我建议创建一个包含棋盘表示的数据结构,以及从起始位置到达棋盘的有序移动列表。移动是一组翻转中中心硬币的坐标。我将在下面将此数据结构的一个实例称为“状态”。

          我的基本算法如下所示:

          Create a queue.
          Create a state that contains the start position and an empty list of moves.
          Put this state into the queue.
          Loop forever:
              Pull first state off of queue.
              For each coin showing tails on the board:
                  Create a new state by flipping that coin and the appropriate others around it.
                  Add the coordinates of that coin to the list of moves in the new state.
                  If the new state shows all heads:
                      Rejoice, you are done.
                  Push the new state into the end of the queue.
          

          如果您愿意,可以对队列长度或移动列表的长度添加限制,以选择放弃的地方。您还可以跟踪您已经看到的电路板以检测循环。如果队列为空而您还没有找到任何解决方案,则不存在。

          此外,一些已经制作的 cmet 似乎忽略了这样一个事实,即该问题只允许出现反面的硬币处于移动中间。这意味着顺序非常重要。如果第一步将硬币从正面翻转到反面,那么该硬币可能是第二步的中心,但它不可能是第一步的中心。类似地,如果第一步将硬币从反面抛到正面,则该硬币不能成为第二步的中心,即使它可能是第一步的中心。

          【讨论】:

          • 啊,这很有趣......是的,我根本没有注意到这个限制 - 很明显。
          【解决方案7】:

          为了详细说明 Federico 的建议,问题是要找到一组 16 个生成器,它们异或在一起给出了起始位置。

          但是,如果我们将每个生成器视为整数模 2 的向量,这就变成了寻找向量的线性组合,它等于起始位置。 解决这个问题应该只是高斯消除(mod 2)的问题。

          编辑: 在考虑了更多之后,我认为这会起作用: 构建所有生成器的二进制矩阵G,并以s 为起始状态。我们正在寻找满足Gx=s(mod 2)的向量x。进行高斯消元后,要么得到这样的向量x,要么发现没有解。

          然后问题是找到向量y 使得Gy = 0x^y 设置尽可能少的位,我认为找到它的最简单方法是尝试所有这些y。由于它们只依赖于G,因此可以预先计算。

          不过,我承认蛮力搜索会更容易实现。 =)

          【讨论】:

            【解决方案8】:

            以行优先顺序读取的网格只不过是一个 16 位整数。问题给出的网格 16 个可能的移动(或“生成器”)都可以存储为 16 位整数,因此问题相当于找到尽可能少的生成器数量,通过以下方式求和按位异或,将网格本身作为结果。我想知道是否有比尝试所有 65536 种可能性更聪明的选择。

            编辑:确实有一种方便的方法来进行暴力破解。您可以尝试所有 1 步模式,然后尝试所有 2 步模式,以此类推。当 n 步模式与网格匹配时,您可以停下来,展示获胜模式并说解决方案至少需要 n 步。枚举所有 n 步模式一个递归问题。

            EDIT2:您可以使用以下(可能是错误的)递归伪代码进行暴力破解:

            // Tries all the n bit patterns with k bits set to 1
            tryAllPatterns(unsigned short n, unsigned short k, unsigned short commonAddend=0)
            {
                if(n == 0)
                    tryPattern(commonAddend);
                else
                {
                    // All the patterns that have the n-th bit set to 1 and k-1 bits
                    // set to 1 in the remaining
                    tryAllPatterns(n-1, k-1, (2^(n-1) xor commonAddend) );
            
                    // All the patterns that have the n-th bit set to 0 and k bits
                    // set to 1 in the remaining
                    tryAllPatterns(n-1, k,   commonAddend );
                }
            }
            

            【讨论】:

            • 你能详细说明这种暴力破解方法的结构吗?我真的在寻找至少一些伪代码来帮助可视化它。你用第二种方法所说的是我在我想要做的问题中已经说过的。我需要更多帮助才能到达那里
            • 我对暴力破解的想法稍微简单一些:有 65536 种可能的方法,从 0 到 65535 的数字很容易映射。我不认为对任何问题都可能有不止一个解决方案(它只使用每次移动 0 或 1 次),但如果有,确定位数很简单 :)
            • 如果他想参加 ACM 比赛,可能他正在寻找优雅/快速/漂亮的解决方案,而我的只是建议。
            • 当然,如果我想快速解决问题,我会选择直接暴力破解(对于 i = 0 到 65535 来说...):D
            • 我认为最简单的解决方案是最优雅的,但它显然会很快运行:)
            【解决方案9】:

            这是一个finite state machine,其中每个“状态”是对应每个硬币价值的 16 位整数。

            每个状态有 16 个出站转换,对应于你抛硬币后的状态。

            绘制完所有状态和转换后,您必须在图中找到从初始状态到状态 1111 1111 1111 1111 的最短路径,

            【讨论】:

            • 与将其视为集合相比,将其视为过渡路径将花费很长时间。这里有一个有用的属性 - 请参阅我的其他 cmets :)
            【解决方案10】:

            您的谜题是经典的Breadth-First Search 候选者。这是因为您正在寻找“移动”最少的解决方案。

            如果您知道目标的移动次数,那么这对于Depth-First Search 来说是理想的。

            那些 Wikipedia 文章包含大量有关搜索工作方式的信息,它们甚至包含多种语言的代码示例。

            如果您确定不会用完堆栈空间,则任何一种搜索都可以是递归的。

            【讨论】:

            • 我不确定我是否会特别称其为广度优先搜索候选者。我认为它是一个简单的蛮力候选者 - 检查 所有 解决方案很容易,我什至不确定我的实现是否算作“广度优先”或“深度优先”。可以说是深度优先..
            • 任何具有状态和转换的模型都可以被视为图问题。图表顶部是起始位置。所有可能的移动都可以是子节点。广度优先使得从搜索中删除重复状态更容易。天哪,你没有很多角色可以发表评论,是吗?
            • 这意味着将其视为一棵树没有什么意义 - 将任何一次尝试视为一个 set 翻转而不是 更有意义翻转路径。无论如何,IMO :) 计算出所有可能的集合比计算出所有可能的路径更容易。
            • @Lee:现在只能选择tails,这绝对是一个广度优先的工作。我目前的深度优先时间太长了:(
            • @Jon:谢谢,我知道,但挑战越多,我就越不确定!您可以调整算法以首先尝试具有最相邻尾部的尾部,因此仍然可以应用一些启发式方法。这将在作者的最后一个网格中几乎立即找到解决方案
            猜你喜欢
            • 1970-01-01
            • 1970-01-01
            • 2011-09-23
            • 2011-04-04
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多