FX5U 要隨機不重複填 1~88 進陣列?用 Fisher-Yates 洗牌,別用重抽法

目錄9 個章節

88 個檢測站,順序不能固定。

治具每一輪要跑完 88 個工件檢測站。如果每次都從第 1 站照號碼測到第 88 站,機構磨損和治具位移會跟著順序累積,測出來的偏差會固定壓在某幾站身上。

順序得打亂才看得出來。

於是需求就變成:在一個長度 88 的陣列裡填進 1~88,每個數字都要有,而且不能重複。

TL;DR 先建立 1~88,再用 Fisher-Yates 原地交換洗牌,執行次數固定 87 次,完全不用檢查重複。 亂數來源自己做一個 16-bit LCG 就夠,不需要 32-bit。 最大的坑在 GX Works3 的型別檢查:Seed 要宣告成 INT,位元遮罩那行得加 INT_TO_WORDWORD_TO_INT,否則直接跳 0x11041F30

FX5U 要隨機不重複填 1~88,該怎麼做?

先把 1~88 照順序填進陣列,再用 Fisher-Yates 從後往前隨機交換。執行次數固定 87 次,不用檢查重複。

換句話說,不要想著”隨機產生 88 個不重複的數字”,而是”先做出一副牌,再洗它”。

這個轉念是整篇的關鍵。牌一開始就是完整的 188,洗完之後還是完整的 188,只是順序變了。你根本不需要檢查有沒有重複,因為結構上就不可能重複。

Fisher-Yates Shuffle 是什麼?

Fisher-Yates Shuffle 是一種洗牌演算法,把一組已知的元素隨機重排,並保證每一種排列出現的機率都相同。

它不產生亂數,也不負責決定要填什麼數字。它只回答一件事:手上這副牌,要怎麼換位置才叫公平。

名字來自兩位統計學家。Ronald Fisher 和 Frank Yates 在 1938 年的統計表格書裡描述了這個方法,當時是紙筆操作:從還沒被挑走的數字裡隨機挑一個抄下來,重複到挑完為止。

1964 年 Richard Durstenfeld 把它改成適合電腦的原地交換版本,不需要另外開一個陣列放結果,直接在原本的陣列上換位置。我們現在寫的都是這一版。

對 PLC 來說,它有兩個性質特別重要:

  • 每種排列出現的機率完全相同,不會偏向某幾種順序
  • 交換次數固定,n 個元素就交換 n-1 次,不會因為運氣差而變慢

第二點是它在 PLC 上勝出的關鍵。

為什麼不要用抽到重複就重抽?

直覺上你可能會這樣寫:

Rand := 188 的隨機數;

IF 這個號碼還沒出現過 THEN
    Data[Index] := Rand;
END_IF;

這確實做得到,但越到後面越慢。

假設已經抽了 87 個,只剩最後一個號碼沒填:

抽中剩下那個號碼的機率 = 1/88

意思是 PLC 平均要空轉 88 次才抽得中。而且這個次數不固定,運氣差的時候會拖更久。

在 PC 上這種寫法無所謂。但 PLC 是掃描式執行,你沒辦法接受一段程式的執行時間會隨機拉長。

Fisher-Yates 沒有這個問題,執行次數固定就是 87 次,不多不少。

實際怎麼寫?

分成三步,環境先講清楚。

前提與環境

  • 三菱 FX5U 系列
  • GX Works3,Structured Text(ST)
  • 陣列 ARRAY[0..87] OF INT,索引 087 對應號碼 188

我實際跑過,87 次交換在一個掃描週期內就跑完,不會卡掃描。

Step 1. 先把 1~88 填進陣列

FOR i := 0 TO 87 DO
    Data[i] := i + 1;
END_FOR;

先把每個位置(也就是每張牌)先放進去。

Step 2. 從後往前隨機交換

Fisher-Yates 的核心只有四行:

FOR i := 87 TO 1 BY -1 DO

    j := 0 到 i 之間的隨機數;

    Temp := Data[i];
    Data[i] := Data[j];
    Data[j] := Temp;

END_FOR;

從最後一格開始,每次在”還沒定案的範圍”裡隨機挑一格跟自己交換,然後把範圍縮小一格。

跑到 i = 1 就結束,因為剩下一格不需要交換。

用五張牌走一次比較好懂:

[1,2,3,4,5]

i = 4,亂數挑中 j = 1
交換 Data[4] 和 Data[1]
→ [1,5,3,4,2]

i = 3,亂數挑中 j = 0
交換 Data[3] 和 Data[0]
→ [4,5,3,1,2]

...

每一格都只會被定案一次,之後不會再被動到。

Step 3. 自己做一個 16-bit 亂數產生器

上面那個”隨機數”要從哪來?

FX5U 雖然有 RND亂數指令可以用,但是因為有時候低階PLC(例如FX2、FX3系列沒有支援亂數指令),所以這邊我直接做了一個(老實說是因為還要找手冊很懶),所以使用 LCG 去寫亂數產生器。

LCG 全名 Linear Congruential Generator,線性同餘產生器。用乘法、加法、取餘數三個運算,從一個 seed 算出下一個看起來很亂的數字。PLC 很適合用它,因為完全不需要複雜運算。

公式長這樣:

下一個值 = (a × 目前值 + c) mod m

16-bit 版本可以用這組參數:

Seed := Seed * 25173 + 13849;
RandVal := Seed AND 16#7FFF;

16#7FFF 是 32767,二進位是 0111 1111 1111 1111。最高那個符號位被清成 0,所以結果一定落在 0~32767,不會是負數。

為什麼要確保非負?因為下一步要拿它取餘數:

j := RandVal MOD (i + 1);

RandVal 如果是負的,j 也會是負的,陣列索引直接爆掉。

完成之後依照codesys寫法基本上會過(前提是型態宣告有正確的話),但在 GX Works3 會跳錯誤訊息,因為三菱的基本型態宣告比較不正統(?)

為什麼貼進 GX Works3 會編譯不過?

上面那兩行看起來很單純,但直接貼進 GX Works3 的 ST 編輯器,編譯會跳錯,而且是兩個錯誤碼一起來:0x11041F300x11041F81

這是我這次花最多時間的地方(演算法本身十分鐘就寫完了,剩下的時間都在跟型別打架)。

根本原因是 GX Works3 的 ST 遵循 IEC 61131-3 標準,對資料型別的檢查非常嚴格。而 INTWORD 雖然都是 16 位元,卻分屬兩個不同的型別家族:

💡 INT 屬於數值ANY_NUM),可以做加減乘除。WORD 屬於位元串ANY_BIT),可以做 ANDORXOR。兩邊不能隱式混用。

這裡有個名詞陷阱要先講清楚,不然後面會越看越亂。

現場口語常常把”一個字”直接講成 WORD,指的是 16 位元這個寬度。但在 ST 裡 WORD 是一個具體的型別名稱,專指位元串。

我這篇用到的標籤,在 GX Works3 的資料類型欄位選的是字[有符號],對應到 ST 就是 INT。後面所有程式碼的宣告都以這個為準,文章裡寫 WORD 的時候一律是指那個型別,不是指字長。

我沒有特別去挑 UINT,純粹是習慣。反正 LCG 本來就靠溢位工作,有號無號都跑得動。

我那兩行剛好一邊踩一個。

Seed := Seed * 25173 + 13849; 是乘加,需要 ANY_NUM

RandVal := Seed AND 16#7FFF; 是位元運算,需要 ANY_BIT。而且 16 進位常數 16#7FFF 會被編譯器直接當成 WORD

於是同一個 Seed,第一行要它是數值,第二行要它是位元串。怎麼宣告都會有一行報錯。

兩個錯誤碼各代表什麼

這兩個是成對出現的,但意思不一樣,分清楚才知道要改哪裡。

0x11041F30:運算式內部的型別不合。

發生在 AND 這種位元運算子上,兩邊的操作數必須都是 ANY_BIT。你的變數是 INT、常數是 WORD,湊不起來。

0x11041F81:賦值時的型別不合。

錯誤訊息原文是 WORD is the data type that is unable to apply to INT。意思是等號右邊算出來是 WORD,但左邊的變數宣告成 INT,編譯器不允許這種隱式賦值。

換句話說,就算你把 AND 兩邊的型別喬好了,如果忘記處理等號左邊,還是會撞上第二個錯誤。

三種解法,我這個案例只能用第二種

方法一:直接把變數宣告成 WORD

官方文件上這是最推薦的做法,只要那個變數本來就是拿來做遮罩、存狀態或處理 16 進位的,改完就直接過。

varWord := varWord AND 16#00FF;   // 宣告成 WORD 就能編譯

但這招在我的案例行不通。Seed 每一輪都要做 Seed * 25173 + 13849,改成 WORD 之後第一行立刻報錯,因為位元串不能參與乘加。

一般情境的最佳解,在這裡反而是不行。

方法二:用型別轉換函式。

變數必須維持 INT 才能做算術,那就在做位元運算的地方轉過去再轉回來。這是我最後採用的版本:

// 標籤宣告(資料類型都選 字[有符號],也就是 INT)
// Seed    : INT   種子數,範圍 -32768 ~ +32767
// RandVal : INT   遮罩後的亂數,範圍 0 ~ 32767

Seed := Seed * 25173 + 13849;
RandVal := WORD_TO_INT( INT_TO_WORD(Seed) AND 16#7FFF );

拆開看第二行在做什麼:

  1. INT_TO_WORD(Seed)INT 轉成 WORD,解掉 0x11041F30
  2. AND 16#7FFF 清掉最高位
  3. WORD_TO_INT(...) 把結果轉回 INT 才賦值,解掉 0x11041F81

方法三:直接軟元件加 :U 修飾符。

如果你不是用 Label 而是直接操作暫存器,可以在元件後面加 :U,明確告訴編譯器把它當 WORD 看:

D10:U := D0:U AND 16#00FF;

這招很省事,但我習慣用 Label 而不是直接用內部位址,所以用不到。你如果是習慣寫 D 暫存器,這條路最短。

⚠️ 第一行的乘法在 INT 上一定會溢位,這是刻意的。LCG 本來就是靠整數溢位達成取餘數的效果,不是 bug。

我一開始想錯的事情

想用 ABS 取代 AND

看到 AND 16#7FFF 的目的是”不要負數”,我第一個反應是:那直接用 ABS() 不就好了。

可以,但不建議。這兩個不是同一件事。

ABS 是數學上取絕對值,AND 是把最高那個位元清掉。同一個輸入給出來的答案完全不同:

Seed = 16#FFFF(有號來看就是 -1)

ABS         : -1 → 1
AND 16#7FFF :    → 32767

更麻煩的是極端值。INT 的範圍是 -32768 ~ 32767,兩邊不對稱,負的那側多一個數。如果 Seed 剛好落在最小值:

ABS(-32768) = 32768

INT 的上限是 32767,放不下 32768,直接踩到型別範圍問題。

我的目的從來就不是”算 Seed 的絕對值”,只是要一個非負的數字拿去做 MODAND 更貼近這個意圖,也沒有極端值的坑。

註:關於溢位,我記得第一次遇到溢位是以寫電子凸輪的時候發現 encoder 溢位的問題,學校數位邏輯課有教溢位,但是沒教過遇到溢位應該要怎麼處理,還好那時候有學了點 C# 所以有猜到怎麼處理溢位問題。

這種需求需要真亂數嗎?

因為亂數都是假亂數,所以令我好奇那有沒有真亂數,差別在哪裡。 所以問了一下充滿智慧 AI 老師給解答,如下:

PRNG 假亂數是靠數學公式從 seed 算出來的亂數值,seed 一樣結果就一樣。

TRNG 真亂數是量測物理雜訊(熱雜訊、振盪器抖動、放射性衰變)所產生的,理論上無法重現。

LCG 屬於 PRNG,只要初始 seed 相同,整串結果就完全相同。

聽起來像缺點,但對我的軟體來說完全不是問題。我要的只是這 88 個測試位置的順序不要每次都一樣,不是在做密碼學金鑰。

我的立場:這種需求硬上真亂數是搞錯重點。 16-bit PRNG 加 Fisher-Yates 就夠了,程式短、執行時間固定、好維護,這三件事在現場比亂數品質重要得多。

真正要處理的反而是 seed。seed 如果寫死,每次開機洗出來的順序都一樣,那就白洗了。拿系統時鐘、掃描計數器或啟動時間去餵它就好。

完整程式碼

// 標籤宣告(資料類型一律選 字[有符號],也就是 INT)
// Data    : ARRAY[0..87] OF INT
// Seed    : INT
// RandVal : INT
// i, j    : INT
// Temp    : INT
// 沒挑 UINT 是習慣問題,LCG 靠溢位工作,有號無號都能跑

// Step 1:填入 1 ~ 88
FOR i := 0 TO 87 DO
    Data[i] := i + 1;
END_FOR;

// Step 2:Fisher-Yates 從後往前洗牌
FOR i := 87 TO 1 BY -1 DO

    // 16-bit LCG 產生亂數
    Seed := Seed * 25173 + 13849;
    RandVal := WORD_TO_INT( INT_TO_WORD(Seed) AND 16#7FFF );

    // 收斂到 0 ~ i
    j := RandVal MOD (i + 1);

    // 交換
    Temp := Data[i];
    Data[i] := Data[j];
    Data[j] := Temp;

END_FOR;

跑完之後 Data 裡就是打亂的 1~88,每個號碼剛好出現一次。


偷懶的部分:Seed 我目前是在啟動時給一個固定值下去測,沒接系統時鐘(或直接抓RND)。所以嚴格講,現在每次開機洗出來的順序都一樣,還沒真的隨機起來😆

常見問題

Q:一定要用 Fisher-Yates 嗎?我用重抽法也能跑。 A:能跑,但執行時間不固定。越接近填滿,抽中未使用號碼的機率越低,最後一個號碼平均要空轉 88 次。PLC 是掃描式執行,一段程式的耗時會隨機拉長是很麻煩的事。Fisher-Yates 固定 87 次交換。

Q:AND 16#7FFF 可以改成 ABS() 嗎? A:不建議。兩者意義不同,AND 是清掉最高位元,ABS 是取絕對值,同一個輸入結果會差很多。而且在型別最小值(INT 是 -32768)時,ABS 的結果 32768 會超出 INT 上限 32767。

Q:0x11041F300x11041F81 差在哪?直接把變數宣告成 WORD 不行嗎? A:0x11041F30 是運算式內部型別不合,AND 兩邊必須都是 ANY_BIT0x11041F81 是賦值型別不合,右邊算出來的 WORD 塞不進宣告為 INT 的變數。宣告成 WORD 確實是官方最推薦的解法,但只適用於那個變數不做算術的情況;像 Seed 每輪都要乘加,改成 WORD 第一行就編不過。用 WORD_TO_INT( INT_TO_WORD(Seed) AND 16#7FFF ) 可以一行同時解掉兩個錯誤。如果你是直接操作 D 暫存器而不是 Label,加 :U 修飾符更快。

Q:每次開機洗出來的順序都一樣,怎麼辦? A:那是 seed 寫死造成的,PRNG 的固有特性。改用系統時鐘、掃描計數器或啟動時間去初始化 seed 就會不一樣。