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_WORD和WORD_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 := 1 到 88 的隨機數;
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 編輯器,編譯會跳錯,而且是兩個錯誤碼一起來:0x11041F30 與 0x11041F81。
這是我這次花最多時間的地方(演算法本身十分鐘就寫完了,剩下的時間都在跟型別打架)。
根本原因是 GX Works3 的 ST 遵循 IEC 61131-3 標準,對資料型別的檢查非常嚴格。而 INT 和 WORD 雖然都是 16 位元,卻分屬兩個不同的型別家族:
💡
INT屬於數值(ANY_NUM),可以做加減乘除。WORD屬於位元串(ANY_BIT),可以做AND、OR、XOR。兩邊不能隱式混用。
這裡有個名詞陷阱要先講清楚,不然後面會越看越亂。
現場口語常常把”一個字”直接講成 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 );
拆開看第二行在做什麼:
INT_TO_WORD(Seed)把INT轉成WORD,解掉0x11041F30AND 16#7FFF清掉最高位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 的絕對值”,只是要一個非負的數字拿去做 MOD。AND 更貼近這個意圖,也沒有極端值的坑。
註:關於溢位,我記得第一次遇到溢位是以寫電子凸輪的時候發現 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:0x11041F30 和 0x11041F81 差在哪?直接把變數宣告成 WORD 不行嗎?
A:0x11041F30 是運算式內部型別不合,AND 兩邊必須都是 ANY_BIT。0x11041F81 是賦值型別不合,右邊算出來的 WORD 塞不進宣告為 INT 的變數。宣告成 WORD 確實是官方最推薦的解法,但只適用於那個變數不做算術的情況;像 Seed 每輪都要乘加,改成 WORD 第一行就編不過。用 WORD_TO_INT( INT_TO_WORD(Seed) AND 16#7FFF ) 可以一行同時解掉兩個錯誤。如果你是直接操作 D 暫存器而不是 Label,加 :U 修飾符更快。
Q:每次開機洗出來的順序都一樣,怎麼辦? A:那是 seed 寫死造成的,PRNG 的固有特性。改用系統時鐘、掃描計數器或啟動時間去初始化 seed 就會不一樣。
半桶水的