StartSkripte ∷ PowerShell

Die nachfolgenden Skripte sind für Schulungs- und Unterrichtszwecke gedacht. Sie können kopiert und in der Microsoft PowerShell ISE oder sogar online ausprobiert/ausgeführt werden. Seit Version 6 ist PowerShell unter Open-Source-Lizenz für diverse Betriebssysteme erhältlich.
 

Wochentag der Geburt und Anzahl der seitdem vergangenen Tage

Die Funktion BirthCalc im nachfolgenden Skript nimmt ein Geburtsdatum im Format JJJJ-MM-TT entgegen und ermittelt sowohl den Wochentag der Geburt als auch die seitdem vergangenen Tage:

function BirthCalc([String]$s) {
  $f = "Du bist vor {0} Tagen an einem {1} geboren worden!"
  $w = "Sonntag Montag Dienstag Mittwoch Donnerstag Freitag Samstag"
  if ($s -match '^\d{4}(-\d\d){2}$' -and ($d = $s -as [DateTime])) {
    $f -f ((Get-Date).Date - $d).Days, (-split $w)[$d.DayOfWeek]
  }
}
BirthCalc 2002-02-20
# Du bist vor 6666 Tagen an einem Mittwoch geboren worden!

Um die Berechnungen für ein anderes Datum vorzunehmen, muss nur der obige Funktionsaufruf vor seiner Ausführung entsprechend angepasst werden. Eine Schnaps- oder runde Zahl hinsichtlich der Anzahl der Lebenstage kann einen guten Vorwand liefern, um einfach mal jemanden überraschend zu kontaktieren und mit Verweis auf das Ereignis zu gratulieren.

GPX-Track verkleinern/anonymisieren

Manchmal möchte man einen Track im GPX-Format weitergeben/veröffentlichen, aber nur die reinen GPS-Koordinaten, nicht darüber hinausgehende Informationen wie Datumsangaben, Uhrzeiten und Geschwindigkeiten. Gründe hierfür können sein, möglichst wenige personenbezogene Daten preiszugeben oder die Dateigröße zu reduzieren. Der nachfolgende Einzeiler kopiert eine Datei namens track.gpx nach track-min.gpx und entfernt dabei die Attribute ele, time, src, sat, course, speed, hdop, vdop, pdop sowie geoidheight. Hinweis: Sollte die Zieldatei bereits existieren, wird sie überschrieben!

(Get-Content -Raw -LiteralPath .\track.gpx) -replace '(?s)[\t ]*<(ele|time|geoidheight|src|sat|[hvp]dop|course|speed).+?\1>\r?\n?' | Set-Content -NoNewline -LiteralPath .\track-min.gpx

Der Parameter -Raw ist erst seit PowerShell 3 verfügbar, -NoNewline setzt sogar mindestens Version 5 voraus; mit Einschränkungen (bzgl. der nachfolgend genannten Aspekte) ist jedoch das Skript auch ohne diese beiden Argumente lauffähig. Die Bereinigung der GPX-Datei wird durch einen einzigen regulären Ausdruck erledigt, welcher an individuelle Bedürfnisse angepasst werden kann und auch für den Fall funktioniert, dass öffnendes und schließendes Schlüsselwort nicht in derselben Zeile stehen. Die Art des Zeilenumbruchs (Windows, UNIX) oder der Zeichenkodierung (ASCII, UTF-8, UTF-8-BOM) bleibt unangetastet.

Konvertierung römischer Zahlen

Das nachfolgende Skript deklariert 3 Prozeduren für den Umgang mit römischen Zahlen:

  1. Die Funktion RomanToInt nimmt eine Zeichenkette mit den römischen Ziffern I, V, X, L, C, D, M entgegen und konvertiert sie in eine natürliche Zahl.
  2. Im Gegenzug wandelt die Funktion IntToRoman eine natürliche Zahl in eine römische um.
  3. Das Cmdlet ConvertTo-Roman nimmt eine Menge von natürlichen und/oder römischen Zahlen entgegen und stellt sie tabellarisch in beiden Formaten dar, wobei implizit auch eine Normalisierung der Schreibweise vorgenommen wird (siehe nachfolgende Erläuterungen zur Variablen $r).

Der Eingabebereich liegt dabei jeweils zwischen 1 und 3999 (bzw. I und MMMCMXCIX). Für notwendige Umrechnungen greift ConvertTo-Roman auf die Funktionen RomanToInt und IntToRoman zurück.

function RomanToInt([String]$s) {
  $n = 0
  $r = '(M{1,3})?' + # Or M{1,9} for numbers up to 9999
       '(?:((?:CC?|XX?|II?)[MD])|((?:DC?|C)C{0,3}))?' +
       '(?:(?<![XI][MD])(?:((?:XX?|II?)[CL])|((?:LX?|X)X{0,3})))?' +
       '(?:(?<!I[MDCL])(?:((?:II?)[XV])|((?:VI?|I)I{0,3})))?'
  if ($s -and $s -cmatch "^$r`$") {
    $h = @{ I=1; V=5; X=10; L=50; C=100; D=500; M=1000 }
    foreach ($k in $Matches.Keys -gt 0) {
      $n += $h[[String]$Matches[$k][$k % 2 - 1]] +
            $h[[String]$Matches[$k][-$k % 2]] *
            ($Matches[$k].Length - 1) * (-1, 1)[$k % 2]
    }
  }
  return $n
}
function IntToRoman([ValidateRange(0,3999)][Int]$n) {
  $s, $a = "", "I", "V", "X", "L", "C", "D", "M"
  for ($i = 0; $n; ($n = [Math]::Floor($n / 10)), ($i += 2)) {
    # The following line is only required for numbers > 3999
    # if ($a.Count - $i -lt 3) { return $a[$i] * $n + $s }
    switch ($n % 10) {
      { 1..3 -eq $_ % 5 } { $s = $a[$i] * ($_ % 5) + $s }
      { 4    -le $_ }     { $s = $a[$i + 1 + ($_ -eq 9)] + $s }
      { 4, 9 -eq $_ }     { $s = $a[$i] + $s }
    }
  }
  return $s
}
function ConvertTo-Roman {
  param([Parameter(ValueFromPipeline=$true)]$InputObject)
  begin {
    $o = 1 | Select-Object -Property Arabic, Roman
    $a = { $e -as [Int] }, { RomanToInt $e }
  }
  process {
    foreach ($e in $InputObject) {
      $o.Arabic = &$a[$e -is [String]]
      $o.Roman = IntToRoman $o.Arabic
      Write-Output -InputObject $o
    }
  }
}
RomanToInt MXXM
# 1980
IntToRoman 1980
# MCMLXXX
ConvertTo-Roman MDCCCCLXXXIIII, MCMLXXXIV, $true, 1666
# Arabic Roman
# ------ -----
#   1984 MCMLXXXIV
#   1984 MCMLXXXIV
#      1 I
#   1666 MDCLXVI
1..3999 | ConvertTo-Roman | Format-Table -Autosize

In der Funktion RomanToInt wird der Variablen $r ein regulärer Ausdruck zugewiesen, durch den festgelegt wird, wie eine römische Zahl aussehen muss. Dieser ist im obigen Skript derart gestaltet, dass auch einige alternative Schreibweisen wie MXXM für 1980 (also Verdoppelung des Zeichens in subtraktiver Stellung) oder VIIII für 9 (d. h. keine Anwendung einer Subtraktionsregel) akzeptiert werden. Soll dies nicht erlaubt sein, so muss das Muster restriktiver formuliert werden, beispielsweise wie folgt:

  $r = '(M{1,3})?' +
       '(?:((?:CC?|XX?|II?)[MD])|((?:DC?|C)C{0,3}))?' +
       '(?:(?<![XI][MD])(?:((?:XX?|II?)[CL])|((?:LX?|X)X{0,3})))?' +
       '(?:(?<!I[MDCL])(?:((?:II?)[XV])|((?:VI?|I)I{0,3})))?'
  $r = '(M{1,3})?' +
       '(?:(CM|CD)|(DC{0,3}|C{1,3}))?' +
       '(?:(XC|XL)|(LX{0,3}|X{1,3}))?' +
       '(?:(IX|IV)|(VI{0,3}|I{1,3}))?'

Mittels des regulären Ausdrucks wird nicht nur eine Zeichenkette aus römischen Zahlzeichen auf Gültigkeit überprüft, sondern auch in Einer-, Zehner, Hunderter- und Tausenderstellen zerlegt. Dabei gibt es für jede Wertigkeit (bis auf die höchste) zwei Gruppierungen, wobei die jeweils erste subtraktive Schreibungen erkennt (z. B. IV oder IIX) und die andere alle additiven/übrigen (z. B. VI oder IIII, aber auch V).

Der Schleifenkörper in der Funktion RomanToInt lässt sich übrigens auch anders formulieren, so dass deutlicher wird, dass es sich um dieselben 3 Werte handelt, die jedoch auf unterschiedliche Art in Berechnungen einfließen – je nachdem, ob eine römische Zifferngruppe in subtraktiver oder additiver Schreibweise vorliegt:

      $n += $h[[String]$Matches[$k][$k % 2 - 1]] +
            $h[[String]$Matches[$k][-$k % 2]] *
            ($Matches[$k].Length - 1) * (-1, 1)[$k % 2]
      $a = $h[[String]$Matches[$k][-1]]
      $b = $Matches[$k].Length - 1
      $c = $h[[String]$Matches[$k][0]]
      $n += &({ $a - $b * $c }, { $a * $b + $c })[$k % 2]

JavaScript-Varianten der eben beschriebenen Algorithmen sind übrigens in diesen beiden Webapps implementiert.

Primfaktorzerlegung

Eine sehr einfache, aber auch zeitaufwändige Methode zur Primfaktorzerlegung ist die Probedivision durch alle potentiellen Teiler größer 1. Dabei bräuchten Vielfache von bereits ausprobierten Divisoren im Weiteren eigentlich nicht mehr betrachtet werden, dies jedoch effizient zu handhaben, ist nicht unbedingt trivial. Zumindest lässt sich recht leicht eine Halbierung der Laufzeit erreichen, indem außer der 2 nur durch ungerade Zahlen dividiert wird:

function Factorize(
  [ValidateRange(1, [UInt64]::MaxValue / 1.01)][UInt64]$Num
                                             , [String]$Tab = "*") {
  [String]$s = ""
  while (!($Num -band 1) -and $Num -gt 2) { $Num /= 2; $s += "2$Tab" }
  for ([UInt64]$i = 3; $i * $i -le $Num; $i += 2) {
    while (!($Num % $i) -and $Num -gt $i) { $Num /= $i; $s += "$i$Tab" }
  }
  $s + $Num
}
Factorize 18264103043276784655
# 5*13*17*16528600039164511
Factorize 11111111111111111111 # Repunit R20
# 11*41*101*271*3541*9091*27961
Factorize 9156001667401012567
# 11*13*17*19*23*29*31*37*41*43*47*53*59
Factorize 5243639155003228607 ", "
# 2243649631, 2337102497
Factorize 1111111111111111111 # Repunit prime R19
# 1111111111111111111

Im Grunde genommen durchläuft oben $i aufsteigend die 2 sowie alle weiteren ungeraden Zahlen bis zur Quadratwurzel von $Num, wobei jedesmal, wenn $Num bzgl. $i ohne Rest teilbar ist, $Num durch $i dividiert und $i als Primfaktor notiert werden. Eine JavaScript-Variante dieses Algorithmus ist übrigens in dieser Webapp implementiert.

Mit der Ziffer 5 endende Zahlen sind Vielfache von 5 und könnten somit im weiteren Verlauf –⁠ analog zu den geraden Zahlen – ebenfalls von der Probedivision ausgenommen werden. Bei der nachfolgenden Code-Optimierung wird dies berücksichtigt, außerdem deutlich seltener die Abbruch­bedingung in der for-Schleife überprüft. Hierdurch verkürzen sich insbesondere lange Ausführungszeiten nochmals merklich, so dass diese Implementierungsvariante nun insgesamt etwa 4-mal schneller als die ursprüngliche naive Methode ist, sämtliche Werte zwischen 2 und $Num als Teiler auszutesten:

  while (!($Num -band 1) -and $Num -gt 2) { $Num /= 2; $s += "2$Tab" }
  while (!($Num % 2) -and $Num -gt 2) { $Num /= 2; $s += "2$Tab" }
  while (!($Num % 3) -and $Num -gt 3) { $Num /= 3; $s += "3$Tab" }
  while (!($Num % 5) -and $Num -gt 5) { $Num /= 5; $s += "5$Tab" }
  for ([UInt64]$i = 3; $i * $i -le $Num; $i += 2) {
  for ([UInt64]$i = 7; $i * $i -le $Num; $i += 4) {
    while (!($Num % $i) -and $Num -gt $i) { $Num /= $i; $s += "$i$Tab" }
    $i += 2
    while (!($Num % $i) -and $Num -gt $i) { $Num /= $i; $s += "$i$Tab" }
    $i += 2
    while (!($Num % $i) -and $Num -gt $i) { $Num /= $i; $s += "$i$Tab" }
    $i += 2
    while (!($Num % $i) -and $Num -gt $i) { $Num /= $i; $s += "$i$Tab" }
  }

Türme von Hanoi

Die nachfolgende rekursive Funktion schlägt eine Zugfolge zur Lösung der Türme von Hanoi vor, ein Knobel- und Geduldsspiel, bei dem ein geordneter (z. B. nach oben hin immer schmaler werdender) Stapel scheibchenweise versetzt werden muss, wobei nur eine einzige weitere Hilfsposition benutzt und zu keinem Zeitpunkt an irgendeiner der 3 Stellen (Start-/Hilfs-/Zielposition) die Ordnung verletzt werden darf.

Als Parameter müssen die Anzahl der Scheiben sowie die Namen der 3 Ablegepositionen (z. B. L für Links, M für Mitte und R für Rechts) angegeben werden. Auf der ersten Ablegeposition (hier: L) ruht der Turm zu Beginn; Ziel ist es, ihn unter Einhaltung der Regeln auf die letzte Ablegeposition (hier: R) zu versetzen. Dazwischen befindet sich die temporär nutzbare Hilfsposition (hier: M). Ausgegeben wird eine Abfolge von Zügen, wobei die jeweiligen Einrückungen der Breite der gerade bewegten Scheibe entsprechen.

function Tower-of-Hanoi {
  param([Int]$num, [String]$src = "L", [String]$tmp = "M", [String]$dst = "R")
  if ($num-- -gt 0) {
    Tower-of-Hanoi $num $src $dst $tmp
    Write-Host -Object (" " * $num + $src + "->" + $dst)
    Tower-of-Hanoi $num $tmp $src $dst
  }
}
Tower-of-Hanoi 3   # alternativ:   Tower-of-Hanoi 3 Links Mitte Rechts
# L->R
#  L->M
# R->M
#   L->R
# M->L
#  M->R
# L->R

Der obige Algorithmus löst die Aufgabe für n Scheiben, indem er

  1. n-1 Scheiben von der Start- zur Hilfsposition versetzt und dabei die Zielposition höchstens temporär nutzt,
  2. dann die breiteste Scheibe von der Start- zur Zielposition zieht und
  3. abschließend die n-1 Scheiben von der Hilfs- zur Zielposition versetzt, wobei diesmal die Startposition lediglich zwischenzeitlich genutzt wird.

Für n ≤ 2 sind die Lösungen trivial. Durch das rekursive Vorgehen ist die Aufgabe ganz allgemein für n Scheiben lösbar, wenn sie es zweimal für n-1 Scheiben ist. Hieraus folgt, dass jede Scheibe doppelt so oft bewegt wird wie die nächstbreitere. Insgesamt baut die Rekursion einen vollständigen Binärbaum der Tiefe n an Funktionsaufrufen auf, wobei in jedem Knoten 1 Zug stattfindet, so dass sich insgesamt 2n-1 Züge und somit ein exponentieller Aufwand ergeben.

Sind die Scheiben von oben nach unten mit Farbe1, …, Farben eingefärbt, dann enthält in jedem Funktionsaufruf der Parameter $num den Farbindex der zu bewegenden Scheibe. Dies lässt sich auf andere Eigenschaften übertragen. Um z. B. die Ausgabe an einen Stapel von (bis zu) 8 Euro-Münzen anzupassen, genügt es, im obigen Quellcode einfach nur das Cmdlet Write-Host etwas anders zu parametrisieren:

    Write-Host -Object (" " * $num + $src + "->" + $dst)
    Write-Host (" 1 Cent", " 2 Cent", "10 Cent", " 5 Cent", "20 Cent",
           " 1 Euro", "50 Cent", " 2 Euro")[$num] "von $src nach $dst"

Anonyme Funktionen und Rückruffunktionen (Callbacks)

Für die nun folgenden Betrachtungen seien zwei Funktionen namens Calculate-Sum und Calculate-Product angenommen, die jeweils mehrere Werte miteinander addieren bzw. multiplizieren:

function Calculate-Sum([Array]$Array) {
  if ($Array.Count) {
    $i = 0
    $n = $Array[$i]
    while (++$i -lt $Array.Count) { $n += $Array[$i] }
    $n
  }
}
Calculate-Sum 3,6,1,5 # Liefert Summe von 3 + 6 + 1 + 5 => 15

function Calculate-Product([Array]$Array) {
  if ($Array.Count) {
    $i = 0
    $n = $Array[$i]
    while (++$i -lt $Array.Count) { $n *= $Array[$i] }
    $n
  }
}
Calculate-Product 3,6,1,5 # Liefert Produkt von 3 * 6 * 1 * 5 => 90

Schnell fällt auf, dass die beiden obigen Implementierungen bis auf Name und Operator identisch sind, d. h. wenn es gelänge, die Operation quasi als Argument zu übergeben, ließen sich die zwei Funktionen zu einer einzigen vereinigen. Eine Möglichkeit, dies umzusetzen, sind Rückruffunktionen, die einer Funktion höherer Ordnung (anonym) im Fluge mitgegeben und von dieser aufgerufen werden. Das könnte dann so aussehen:

function Calculate-Value([Array]$Array, [ScriptBlock]$Callback) {
  if ($Array.Count) {
    $i = 0
    $n = $Array[$i]
    while (++$i -lt $Array.Count) { $n = &$Callback $n $Array[$i] }
    $n
  }
}
Calculate-Value 3,6,1,5 { param($x, $y) $x + $y } # Liefert Summe => 15
Calculate-Value 3,6,1,5 { param($x, $y) $x * $y } # Liefert Produkt => 90

Formal entspricht das bisherige Vorgehen den Umformungen

n += ainn + ainf + (n, ai)mitf + : x, yf + (x, y) := x + y,
n = ainn ⋅ ainf(n, ai)mitf: x, yf(x, y) := x ⋅ y.

Auf dieser Idee basierend ist nachfolgend die Funktion Reduce-Array ganz allgemein dazu in der Lage, ein Array auf einen einzigen Wert zu reduzieren. Wie dies konkret geschehen soll (beispielsweise per Konkatenation, Summen-/Produktbildung oder Rückgabe des ersten/letzten/kleinsten/größten Elements), wird mittels einer Rückruffunktion spezifiziert, die zwei Werte entgegennimmt und einen zurückliefert. Durch deren iterative Anwendung lässt sich eine mehrelementige Eingabe letztlich zu einem einzelnen Ergebnis verdichten. Optional kann der Funktion als weiteres Argument ein initialer Startwert mitgegeben werden, wie etwa Null, eine leere Zeichenkette oder ein leeres Array.

Bei Bedarf kann die Zeile im Schleifenkörper erweitert werden, um zusätzlich den Laufindex sowie das Array verfügbar zu machen und die Syntax derjenigen von Array.prototype.reduce() unter JavaScript anzugleichen:

Im Wesentlichen paart der Reduce-Array zugrundeliegende Algorithmus fortwährend Datenobjekte und lässt sie jeweils durch die Rückruffunktion fließen, und zwar nach folgendem Schema:

Selbstverständlich kann der Rückgabewert wieder ein Array sein:

Reduce-Array lässt sich übrigens auch in mathematischen Funktionen und Reihenberechnungen einsetzen:

Die Bit-Verschiebungen -shl und -shr gibt es erst seit PowerShell 3; da jedoch im Dualsystem jeder Versatz um eine Stelle nach links eine Verdopplung bewirkt, ist die Multiplikation mit 2 hierzu äquivalent. Aus analogen Gründen ist ferner in den obigen Implementierungen $y -band 1 ergebnisgleich zu $y % 2, außerdem entspricht die binäre ODER-Verknüpfung einer geraden Zahl mit 1 deren Inkrementierung um 1. Einen deutlichen Geschwindig­keits­vorteil scheinen Bit-Operationen gegenüber arithmetischen Operatoren allerdings nicht zu bieten.

Nachfolgend wird die rekursive Verwendung einer Rückruffunktion demonstriert. Ist diese anonym, kann ihr Quellcode mittels der Eigenschaft MyCommand.ScriptBlock der automatischen Variablen $MyInvocation ausgelesen werden. Die Definition einer benannten Funktion ist dagegen über den Provider Function: abrufbar.

Reduce-Array @(3,@(@(6),1)),5 { param($x, $y)
  return $x + 1 # Zählt Elemente ergebnisgleich zu (@(3,@(@(6),1)),5).Count => 2
  if ($y -is [Array]) { Reduce-Array $y $MyInvocation.MyCommand.ScriptBlock $x }
    else { $x + 1 } # Zählt Elemente rekursiv => 4
} 0

function Callback($x, $y) {
  if ($y -is [Array]) { Reduce-Array $y ${Function:Callback} $x }
    else { $x + 1 } # Zählt Elemente rekursiv => 4
}
Reduce-Array @(3,@(@(6),1)),5 ${Function:Callback} 0

Reduce-Array kommt mit einigen Sonderfällen zurecht:

Set-StrictMode -Version 3.0
Reduce-Array -Value 4 # Leeres Array => Gibt Startwert 4 zurück
Reduce-Array @() { 4 } # Leeres Array => Gibt $null zurück
Reduce-Array $null { 4 } # $null statt Array => Gibt $null zurück
Reduce-Array @() -Value 4 # Leeres Array => Gibt Startwert 4 zurück
Reduce-Array @($null) { 4 } # Array mit $null => Gibt Array-Element zurück
Reduce-Array @($null) -Value 4 # Keine Callback-Funktion => Gibt $null zurück
Reduce-Array @() $null 8 # $null statt Callback-Funktion => Bemängelt Argument
Reduce-Array @(4) $null 8 # $null statt Callback-Funktion => Bemängelt Argument
Reduce-Array @(4) { $_ } 8 # Undefinierte Variable $_ => Bemängelt Callback-Code
Set-StrictMode -Off

Teile dieses Abschnitts einschließlich entsprechender Portierungen nach JavaScript und PHP wurden der Wikipedia als Beispiele beigetragen.

Benutzerdefinierter Alarm

Nachfolgend führt Alert-Object in einer Endlosschleife periodisch eine Rückruffunktion mit einem Objekt als Argument aus und erzeugt einen Piepton, solange hintereinander das gleiche Ergebnis retourniert wird. Das Wiederholungsintervall ist auf 15 Sekunden voreingestellt, lässt sich aber per Parameter nach Belieben ändern. Durch Betätigung von Strg+C (in der Shell) oder der Schaltfläche Vorgang beenden (o. ä. in der IDE) kann der Lauf abgebrochen werden.

Mit einer geeigneten Rückruffunktion als Argument kann Alert-Object zur Überwachung eines längeren Downloads (hier: Video-XXL.kGOYic_m.mp4.part) eingesetzt werden. Stockt dieser, dann bleibt die Dateigröße konstant und es wird der Wert retourniert, mit dem auch der Aufruf erfolgte; wird dagegen die Datei nicht gefunden, wird dasselbe (oder $null) zurückgeliefert. Da sich in all den Fällen das Ergebnis nicht mehr verändert, kommt es zum akustischen Signal.

Alternativ lässt sich eine Weckfunktion realisieren, indem die aktuelle Zeit mit einer gewünschten (hier: 20:25 Uhr am 25.02.2025 – eine reine Uhrzeit würde sich auf den aktuellen Tag und ein bloßes Datum auf Mitternacht beziehen) verglichen wird. Um ein Klingeln zu bewirken, muss lediglich der Wert des ersten Aufrufparameters unmodifiziert zurückgegeben werden.

Alert-Object '2025-02-25 20:25' {
  param($ts, $alarm)
  Write-Host -Object (Get-Date -Format '[yyyy-MM-dd HH:mm:ss]'), $alarm
  if (($now = Get-Date) -lt (Get-Date -Date $alarm)) { $now } else { $ts }
} 5

Jeweils entscheidend ist in beiden Anwendungs­szenarien einzig die if-Anweisung, das Cmdlet Write-Host sorgt nur für eine regelmäßige Kontrollausgabe. Statt oder neben der Alarmierung könnten auch andere Aktionen erfolgen, z. B. ein Herunterfahren des Rechners.

Tic-Tac-Toe / Drei gewinnt

Bei diesem klassischen, einfachen Strategiespiel für 2 Personen geht es darum, als Erster eine Horizontale, Vertikale oder Diagonale auf einem Gitter von 3×3 Feldern vollständig in den Besitz zu nehmen. Dafür besetzen zwei Spieler abwechselnd ein noch freies Feld auf dem Spielbrett. Wer anfängt, hat einen kleinen Vorteil und sollte deshalb per Los bestimmt werden.

Das nachfolgende Skript ermöglicht es, dieses Spiel gegen den Computer anzutreten. Wegen der Interaktion mit dem Menschen kann der Quellcode leider nicht online ausgeführt werden. Stattdessen sollte er kopiert und in der PowerShell ISE eingefügt werden, um ihn dort zu starten (jedoch gibt es alternativ an anderer Stelle auch eine im Web-Browser lauffähige JavaScript-Portierung). Auf dem Spielbrett werden durch den Computer besetzte Felder durch das Zeichen # markiert, und die vom Menschen belegten durch X. Felder, die keinem gehören und insofern noch frei sind, werden mit einer Ziffer versehen. Wenn der Mensch am Zug ist, gibt dieser einfach eine der angezeigten Nummern zwischen 1 und 9 ein, um das entsprechende Feld zu besetzen.

PowerShell-Quellcode für Tic-Tac-Toe / Drei gewinnt
  1. $h = @{ TITLE =   "TIC-TAC-TOE" # for PowerShell >=2.0, written by mawe-web.de
  2.         HOWTO = @("My symbol is '#', yours 'X';",
  3.                   "enter a field number between",
  4.                   "1 and 9 when it is your turn")
  5.        MYTURN =   "My move".PadRight(9)
  6.      YOURTURN =   "Your move"
  7.         MYWIN =   "Sorry, I have won."
  8.       YOURWIN =   "Congratulations, you won!"
  9.           TIE =   "The game ended in a tie..."
  10.           PAD = @{ CALCULATOR = 7..9 + 4..6 + 1..3; PHONE = 1..9 }.CALCULATOR
  11. }
  12. # Decision making function
  13. function f { param($p # Patterns to examine
  14.                  , $m = 0 # Operating mode / expectancy value (0, 1, 2 or 3)
  15.      <# , $n = $n, $a = $a #>) # Read access variables defined outside: $n, $a
  16.   $s = "" # Initialize the list of suitable moves
  17.   foreach ($e in $p) { # Loop through the patterns
  18.     $d = 0 # Initialize the delta/score
  19.     $t = "" # Clear temporary string variable used for various purposes
  20.     foreach ($c in [String[]][Char[]]"$e") { # Loop through the positions...
  21.       if ($a[$c]) { $d += $a[$c] } # ...and calculate the pattern score...
  22.         else { $t += $c } # ...and determine blank fields
  23.     }
  24.     if ($m -eq 3 -and [Math]::Abs($d) -eq 3)
  25.       { return -"$d".Length } # Game over! The winner is: -2=human, -1=machine
  26.     if (!$m -and $t -eq $e -or # If mode=0 and each pattern field is blank...
  27.         ($m -eq 3 -and $d -eq 2) -or # ...or mode=3 and the delta is +2...
  28.         ($m -and [Math]::Abs($d) -ge $m)) # ...or delta >= expectancy value...
  29.       { $s += $t } # ...then add the blank fields to the list of useable moves
  30.   }
  31.   if ($n -lt 6 -and ($t = $s -replace "[2468]"))
  32.     { $s = $t } # Prefer odd fields during the first moves
  33.   if ($s) { [Int][String]$s[(Get-Random -Maximum $s.Length)] } # Select a move
  34.     else { 0 } # Otherwise return 0 since there is no move to recommend
  35. }
  36. # Main procedure
  37. Write-Host -Object ((,$h.TITLE + $h.HOWTO) -join "`n".PadRight(3)) # Say hello
  38. $a = ,0 * 10 # Game board with fields numbered from 1 to 9
  39. $p = 123, 147, 159, 258, 357, 369, 456, 789 # List of all winning patterns
  40. $i = 10 # Index (must be 10 to skip Read-Host cmdlet during first loop)
  41. $n = -(Get-Random -Maximum 2) # Roll the dice who starts: -1=human, 0=machine
  42. while (($f = f $p 3) -ge 0 -and $n -le 9) { # While exit condition is not met:
  43.   while (!(1..10 -eq $i) -or $a[($i %= 10)] -or $a[$i]--)
  44.     { $i = Read-Host -Prompt $h.YOURTURN } # (1) Get and set the human's move
  45.   if (2..10 -eq ($n += 2) -and # (2) Calculate/do the machine's move...
  46.       (($i = f $p 3) -or # ...using OR-linked ruleset
  47.        ($i = f $p 2) -or
  48.        ($i = f (,5)) -or
  49.        ($a[5] -ge 0 -and (f (,1379) 2) -and ($i = f (,2468))) -or
  50.        ($i = f (12369, 12347, 14789, 36789) 2) -or
  51.        ($i = f (1..9))
  52.       ) -and $i -gt 0 -and ++$a[$i]) { Write-Host -Object "$($h.MYTURN): $i" }
  53.   Write-Host -NoNewline -Object "", # (3) Display the game board
  54.     ($h.PAD | ForEach-Object -Process { "${_}#X"[$a[$_]] + " `n"[!($_ % 3)] })
  55. }
  56. Write-Host -Object $h[("TIE", "YOURWIN", "MYWIN")[$f]] # Announce the result

Da sämtliche Texte gemeinsam in der Variablen $h tabellarisch hinterlegt sind, ist eine sprachliche Anpassung mit keinem großen Aufwand verbunden:

$h = @{ TITLE =   "TIC-TAC-TOE"
        HOWTO = @("My symbol is '#', yours 'X';",
                  "enter a field number between",
                  "1 and 9 when it is your turn")
       MYTURN =   "My move".PadRight(9)
     YOURTURN =   "Your move"
        MYWIN =   "Sorry, I have won."
      YOURWIN =   "Congratulations, you won!"
          TIE =   "The game ended in a tie..."
        HOWTO = @()
       MYTURN =   "Mein Zug"
     YOURTURN =   "Dein Zug"
        MYWIN =   "Du wurdest besiegt."
      YOURWIN =   "Du hast gewonnen!"
          TIE =   "Unentschieden."
          PAD =   7..9 + 4..6 + 1..3
}

Insofern könnte ein möglicher Spielverlauf wie folgt aussehen, so dass sich an der Ausgabe im Nachhinein recht gut nachverfolgen lässt, wie sich eine Partie entwickelt hat: (sowohl im Original als auch in Deutsch)

TIC-TAC-TOE
  My symbol is '#', yours 'X';
  enter a field number between
  1 and 9 when it is your turn
My move  : 5
 7  8  9
 4  #  6
 1  2  3
Your move: 6
My move  : 3
 7  8  9
 4  #  X
 1  2  #
Your move: 7
My move  : 2
 X  8  9
 4  #  X
 1  #  #
Your move: 8
My move  : 1
 X  X  9
 4  #  X
 #  #  # 
Sorry, I have won.
TIC-TAC-TOE
Mein Zug: 5
 7  8  9
 4  #  6
 1  2  3
Dein Zug: 6
Mein Zug: 3
 7  8  9
 4  #  X
 1  2  #
Dein Zug: 7
Mein Zug: 2
 X  8  9
 4  #  X
 1  #  #
Dein Zug: 8
Mein Zug: 1
 X  X  9
 4  #  X
 #  #  # 
Du wurdest besiegt.

Der Funktion f() im obigen Quellcode werden Muster von Feldern in Form von Ziffernfolgen sowie ein Erwartungswert als Parameter übergeben, ferner greift sie lesend auf die extern deklarierten Variablen $n und $a zu [Zeilen 13-15]. Sie berechnet für jedes Muster eine Punktzahl, die der Differenz zwischen den eigenen und generischen Steinen auf seinen Feldern entspricht, und notiert sich außerdem die Nummern der noch unbesetzten Felder [Zeilen 20-23], z. B.: Gehören im Muster 123 die Felder 2 sowie 3 ein und derselben Partei und ist das Feld 1 leer, dann führt dies zur Punktzahl 2 und Notierung der Ziffer 1. Wenn die Differenz/Punktzahl mindestens dem Erwartungswert entspricht, werden die Nummern der unbesetzten Felder der Ergebnismenge hinzugefügt; für den Erwartungswert 0 wird dies jedoch nur dann gemacht, wenn sämtliche Felder des Musters unbesetzt sind [Zeilen 26-29]. Aus der Ergebnismenge wird eine Nummer zufällig ausgewählt und als Zugvorschlag von der Funktion zurückgegeben, oder 0, wenn die Ergebnismenge leer ist [Zeilen 33-34]. Für den Erwartungswert 3 findet die Funktion Muster, die vollständig von einer der beiden Spielparteien besetzt sind, und gibt in diesem Fall -1 oder -2 zurück, je nachdem, ob das gefundene Muster im Besitz des Computers oder des menschlichen Gegners ist [Zeilen 24-25]. Ansonsten findet die Funktion für den Erwartungswert 3 zudem alle Muster mit der Punktzahl 2, die der Computer mit nur einem Zug auf das noch freie Feld vervollständigen kann; ggf. gibt sie die Nummer eines der Freifelder als Zugempfehlung zurück [Zeile 27]. Solange die Partie noch jung ist und bislang weniger als 5 Züge erfolgt sind, wird der Zugvorschlag –⁠ wenn möglich – nur aus den ungeraden Nummern der Ergebnismenge ausgewählt [Zeilen 31-32].

In der Hauptprozedur des Skripts wird die Funktion f() wiederholt mit verschiedenen Mustern und Erwartungswerten aufgerufen, und zwar solange, bis ein Zugvorschlag zurückgegeben wird und getätigt werden kann [Zeilen 45-52]; dabei wird von der Booleschen Kurzschlussauswertung umfangreich Gebrauch gemacht. Ferner werden in der Hauptschleife der Zug des menschlichen Gegners (als Ziffer zwischen 1 und 9) erfragt, geprüft und gesetzt [Zeilen 43-44] sowie die aktuelle Spielsituation visualisiert, wobei vom Computer besetzte Felder mit einer Raute (#) markiert werden und alle gegnerischen mit einem Kreuz (X) [Zeilen 53-54]. Die Partie wird beendet, wenn eine Horizontale, Diagonale oder Vertikale durch eine der Parteien alleinig und vollständig besetzt ist oder es kein unbesetztes Feld mehr gibt; wer anfängt, bestimmt der Zufall [Zeilen 41-42].

Der im Skript implementierte Algorithmus kämpft nicht ganz so verbissen um einen Sieg wie die von Newell und Simon verwendete Strategie, sollte aber zumindest ein Unentschieden erzielen. Und er kommt mit äußerst wenigen Code-Zeilen aus: Gerade mal 2 ineinander verschachtelte Schleifen [Zeilen 16-30], 4 If-Bedingungen [Zeilen 21-22, 26-29, 31-32 und 33-34] und 6 Zugregeln [Zeilen 46-51] bilden den Kern der Entscheidungsfindung; das meiste andere ist eher der Ablaufsteuerung oder Benutzer­schnittstelle zuzurechnen. Durch Auskommentierung der Anweisungen in den Zeilen 47, 48, 49, 50 und/oder 31-32 kann der Computer in seiner Spielweise geschwächt und –⁠ evtl. mit ein bisschen Glück – geschlagen werden.

Siehe auch:
darebalogun/tic-tac-toe (in C++)
WesleyyC/Tic-Tac-Toe (in Python)
Creating Tic-tac-toe in Swift: Artificial intelligence

Und zum Schluss ist alles im Fluss …

… aber in welchem? – Obwohl der nachfolgende Zeichensalat so aussieht, als sei das Haustier kurz mal eben über die Tastatur geflitzt, schafft es PowerShell, ihn zu einem durchaus lesbaren Ergebnis auszuwerten. Wer erfahren im Umgang mit der Skriptsprache ist und Spaß am Rätseln hat, kommt vielleicht auch ohne eine explizite Ausführung darauf, welche Ausgabe dieser Einzeiler bewirkt:

,[Char](6%-4*''''[-1]-1)+("$("p","s")"[@(1/.5)+-!!8..!0]|%{"$_".Trim()*2})-Join[Char](1*3*5*7)#~9)

Tipp: Forme den obigen Ausdruck um und vereinfache/reduziere ihn sukzessive!


Alle Angaben sind ohne jegliche Gewähr. Der Autor übernimmt keinerlei Verantwortung oder Haftung für Fehler oder Ungenauigkeiten, die in diesem Dokument auftreten können. Anmerkungen, Ergänzungen und Verbesserungsvorschläge zu diesem Artikel werden gerne entgegengenommen.