元規劃:使用規劃器解決數學問題

banq發表於2024-07-03


使用規劃器程式設計(planner programming)解決數學問題的文章。

規劃器程式設計和動態規劃 (DP)比較
規劃器程式設計使用搜尋來查詢一系列操作,而 動態規劃(dynamic programming :DP)) 則將問題分解並重新使用子問題的解決方案。

規劃器程式設計和動態規劃 (DP) 是解決複雜問題的相關但不同的技術:

  • 規劃器程式設計涉及將問題建模為一系列動作和狀態,並使用廣度優先搜尋 (BFS) 等規劃演算法來找到到達目標狀態的最短路徑。本文演示瞭如何使用規劃器程式設計來解決涉及達到目標字元數的數學問題。
  • 動態規劃是一種解決複雜問題的方法,它將複雜問題分解為更簡單的子問題,每個子問題只解決一次,並儲存解決方案以避免冗餘計算。它對具有最優子結構和重疊子問題很有效。

兩者都旨在有效地解決複雜問題。

案例
文章中討論了一個有趣的問題:
假設開始時有一個空白文件,寫入一個字母 "a",然後只能使用 "全選"、"複製" 和 "貼上" 這三個功能

  • 目標是找到達到至少100,000個 "a" 的最少步驟數。("全選"、"複製 "和 "貼上 "這三個操作中的每一個都算作一個步驟)
  • 如果沒有指定目標數,,是否存在一個通用公式來得到確切數量的 "a"?

首先是使用分析方法尋找解決方案,使用C++程式透過廣度優先搜尋(BFS)來找到解決方案。這保證找到最短路徑,但會阻止某些最佳化,例如融合選擇和複製步驟

include <iostream>
include <queue>

enum Mode
{
    SELECT,
    COPY,
    PASTE
};

struct Node
{
    int noOfAs;
    int steps;
    int noOfAsCopied;
    Mode mode;
};

int main()
{
    std::queue<Node> q;

    q.push({1, 0, 0, SELECT});

    while (!q.empty())
    {
        Node n = q.front();
        q.pop();

        if (n.noOfAs >= 100000)
        {
            std::cout << n.steps << std::endl;
            break;
        }

        switch (n.mode)
        {
        case SELECT:
            q.push({n.noOfAs, n.steps + 1, n.noOfAsCopied, COPY});
            break;
        case COPY:
            q.push({n.noOfAs, n.steps + 1, n.noOfAs, PASTE});
            break;
        case PASTE:
            q.push({n.noOfAs, n.steps, n.noOfAsCopied, SELECT});
            q.push({n.noOfAs + n.noOfAsCopied, n.steps + 1, n.noOfAsCopied, PASTE});
            break;
        }
    }

    return 0;
}

由於 BFS 的一個有趣特性,這保證能找到最短的解決方案:節點到原點的距離永遠不會減小。如果您在節點 X 之後評估節點 Y,則 Y.dist >= X.dist,這意味著第一個有效解決方案將是最短的解決方案。


這也存在一個缺點,就是無法使用洞察力。我們應該能夠將選擇和複製步驟融合在一起,這意味著我們只需要兩個操作(selectcopy、paste),而不是三個操作(select、copy、paste),其中 selectcopy 所需的步驟是貼上的兩倍。

但我們不能這樣最佳化,因為這會破壞單調性。我們現在要把 n+1 步和 n+2 步混合推送到佇列中,沒有辦法保證所有 n+1 步都在 n+2 步之前被搜尋到。

我想我應該嘗試用規劃語言來解決這個問題,這樣我們就能同時獲得優雅的解決方案和最佳化。

規劃Planning
規劃的大致思路是,你提供:

  • 一個初始狀態、
  • 一組操作
  • 和一個目標,

然後工具會找出達到目標的最短操作序列。

import planner.
import util.

main =>
  Init = $state(1, 0) % one a, nothing copied
  , best_plan(Init, Plan, Cost)
  , nl
  , printf(<font>"Cost=%d%n", Cost)
  , printf(
"Plan=%s%n", join([P[1]: P in Plan], " "))
  .

我們將系統狀態儲存為兩個整數:列印的字元數和剪貼簿上的字元數。由於我們將融合選擇和複製,因此我們不需要跟蹤所選字元的數量(與 C++ 不同)。

final(state(A, _)) => A >= 100000.

action(state(A, Clipboard), To, Action, Cost) ?=>
  NewA = A + Clipboard
   , To = $state(NewA, Clipboard)
   , Action = {<font>"P", To}
   , Cost = 1
   .

貼上操作只是將剪貼簿新增到字元數中。由於 Picat 是一種研究語言,因此將表示式放在結構中有點奇怪。如果我們這樣做,它$state(1 + 1)會將其儲存為字面意思 $state(1 + 1),而不是state(2)。

此外,在函式定義中,定義時必須使用美元符號,但在模式匹配時不能使用美元符號。我不知道為什麼。

action(state(A, Clipboard), To, Action, Cost) ?=>
  To = $state(A, A)
   , Action = {<font>"SC", To}
   , Cost = 2
   .

就是這樣!這就是整個程式。執行它得到:

Cost=42
Plan=SC P P SC P P SC P P SC P P SC P P SC 
     P P SC P P SC P P SC P P P SC P P P

為了找到是否存在一個恰好等於100,000 的序列,我們只需要做一處更改:

- final(state(A, _)) => A >= 100000.
+ final(state(A, _)) => A = 100000.

作者能夠找到最短的操作序列(選擇、複製、貼上),以達到恰好 100,000 個字元,耗時 43 步。

另一方面,即使進行了一些最佳化,我也無法讓它找到一條能精確生成 100 001 個字元的路徑。這是因為最短路徑的長度超過 9000 步!

  • 找不到一個恰好達到 100,001 個字元的序列,因為最短路徑長度超過 9,000 步

元規劃(Metaplanning)
規劃讓我如此著迷的一個原因是,如果一個問題現在很簡單,你就可以嘗試一下。比如,如果我想新增“刪除一個字元”作為動作,這很容易:

action(state(A, Clipboard), To, Action, Cost) ?=>
  A > 0
  , NewA = A - 1
  , To = $state(NewA, Clipboard)
  , Action = {<font>"D", To}
  , Cost = 1
  .

這並不會讓超過或達到 100 000 步變得更容易,但會讓達到 100 001 步變得只需 47 步,而不是 9000 步。

新增“刪除一個字元”操作,將達到 100,001 的步驟從 9,000 步減少到 47 步。

這體現了規劃探索不同解決方案和最佳化的強大力量。

經過一些調整,我還可以問一些問題,比如 "哪些數字的最短路徑最簡單?哪個數字最多?

規劃真的很酷。

總結
規劃語言的核心思想是提供一個初始狀態、一組動作和目標,然後工具會找到達到目標的最短動作序列。文章中使用Picat語言來實現規劃解決方案,並展示瞭如何定義狀態、動作和目標,以及如何執行規劃程式來找到成本和計劃。

 

相關文章