今日を乗り切るExcel研究所

Excel に働かされていませんか

複数リストから全ての組み合わせデータを作るVBSとPowerShellのスクリプト

今回も、しつこく組み合わせデータを生成します。今回は VBS と PowerShell を使います。

組み合わせ大爆発

以前、リストのファイルから組み合わせデータを生成するバッチとクエリを作成しました。

手作業ではとても面倒でやっていられない作業を自動化したつもりでした。

ところが意外にも、そもそも手作業では不可能な何十万行にもなる大量データの生成にも利用されているようです。

そうなると、今度は処理速度が問題となります。 特にバッチは実用に耐えないようです。

そこで本記事では、処理系を変えて処理速度の改善を試みます。

本記事は内容的に技術者向けとなりますので、基礎的な技術説明は省かせていただきます。

高速化をめざして

前回の BAT スクリプトが遅いのは、再帰処理のためにファイルや標準出力へのアクセスが頻繁になっているからと考えられます。

そこで、まず思いつく改善策としては、入力となるリストデータと組み合わせ結果のデータをメモリにため込んでおき、最後に一括で生成データを出力するような改修です。

しかしバッチの言語は貧弱すぎてその程度のプログラムを組むのも簡単ではありません。

代わりに本記事では、 Windows に標準で組み込まれている別の処理系 ― VBScript と PowerShell ― で実装してみることにします。

VBScript で組み合わせを生成する

まずは要望もあった VBScript(VBS)で実装をしてみました。

下記 VBS スクリプトを実行すると、 BAT 版と同様に、各リスト項目の組み合わせをカンマ区切りで出力します。

BAT 版と異なるのは、出力データを標準出力でなく、テキストファイルとして保存するようにした点です。 VBS の標準出力が非常に遅いことが判明したからです。

その他仕様として以下のようになっています。

  • テキストファイルの文字コード(エンコーディング)はシフトJIS です
  • 入力ファイルの1行目はヘッダー行とみなし読み飛ばされますので、出力には含まれません
  • 出力ファイル名はとりあえず [スクリプト名].out.txt の固定で、カレントディレクトリ配下に保存され、都度上書きされます

さて処理速度の結果ですが、100 行のリストからなるテキストファイル 3 個(100x100x100で100万行の結果となる)を処理させたところ、 BAT 版では約 90 秒かかっていたのが、 この VBS 版では約 1 秒までに短縮することができました。

それ以上のオーダーでも試したのですが、文字列の最大長の問題に突き当たりました。 VBS の文字列の最大長がいくつなのか、資料が見つからなかったのですがどうも 10 億文字程度1のようです。 ということで、1億行ぐらいの生成が限界ということになります。

もちろん適度なサイズで小分けして出力するような修正すればさらに大きなデータ量の生成も対処可能です。 修正自体は難しくないのですが、そうしたところですぐに今度は配列の最大長やメモリの限界に突き当たるでしょう。

 



 

Option Explicit

if (Wscript.Arguments.Count = 0) then
    WScript.Echo("Usage: cscript " & WScript.ScriptName & " list1.txt list2.txt ...")
    WScript.Quit
end if

dim fso
set fso = CreateObject("Scripting.FileSystemObject")

dim gInputLists  ' ファイルから読み込まれた複数リストの配列
dim gOutputLines ' 出力行の配列 

function crossjoin(line, listIdx, lineNum)
    dim item
    if listIdx > 0 then
        for each item in gInputLists(listIdx)
            lineNum = crossjoin(line & "," & item, listIdx - 1, lineNum)
        next
    else
        for each item in gInputLists(listIdx)
            gOutputLines(lineNum) = MID(line, 2) & "," & item
            lineNum = lineNum + 1
        next
    end if
    crossjoin = lineNum
end function

function loadLists(fileNames)
    dim lists()
    redim lists(fileNames.Count - 1)
    
    dim i   
    for i = 0 to fileNames.Count -1
        dim reader, data, list
        set reader = fso.OpenTextFile (fileNames(i))
        reader.readLine() ' 先頭1行目(ヘッダー行)を読み飛ばす
        data = reader.readAll()
        reader.Close

        ' 行の配列に分割
        list = Split(Replace(data & vbCrLf, vbCrLf & vbCrLf, vbCrLf), vbCrLf)
        redim Preserve list(UBound(list)-1) ' 終末の空行を削除
        lists(i) = list
    next
    loadLists = lists
end function

sub saveList(list, fileName)
    dim writer
    Set writer = fso.CreateTextFile(fileName, True)
    writer.Write(Join(list, vbCrLf))
    writer.Close
end sub

gInputLists = loadLists(Wscript.Arguments)

dim totalLineNum
totalLineNum = 1
dim list
for each list in gInputLists 
    totalLineNum = totalLineNum * (UBound(list) + 1)
next
redim gOutputLines(totalLineNum-1)

call crossjoin("", UBOUND(gInputLists), 0)

call saveList(gOutputLines, ".\" & WScript.ScriptName & ".out.txt")

ところで VBS スクリプトにもいくつか問題点があります。

一つはご承知のように、 VBS が Windows 10/11 にとって非常に古い技術で、今や使う人もほとんどなくドキュメントも失われつつある古代技術になっていることです。 64 bit 版のサポートもなく、セキュリティー的な面から懸念もあって、もはや Windows からも干されている状態です。

もっと言えば VBS とその実行環境である WSH は、いつ Windows から削除されるか危ういので、個人的な単発作業ならともかく、日常業務には採用できないでしょう。

さらに実用上の問題として、テキストのエンコーディングに シフトJIS か UTF-16 しか対応できない点があります。

この VBS スクリプトに UTF-8 の日本語テキストを流すと文字が壊れます。

今どき日本語データは UTF-8 で運用されているのが当たり前なので、はっきり言って使い物になりません。

PowerShell で組み合わせを生成する

PowerShell なら、現行 Windows の最新シェル環境であり、UTF-8 を扱うことも可能です。

処理速度的にも VBS をも凌駕するものと期待できます。

そこで、上記プログラムを PowerShell で書き直してみました。

下記 PS 版スクリプトの使い方は VBS 版と同様です。

出力はカンマ区切りされた組み合わせテキストとしてカレントディレクトリのファイルに保存されます。 標準出力は PowerShell でも遅かったのと、BOMなしの UTF-8 を出力したかったからです。

その他仕様は以下の通り。

  • 入力ファイルと出力ファイルのエンコーディングは BOM なしの UTF-8 です。
  • 入力ファイルの1行目はヘッダー行とみなし読み飛ばされますので、出力には含まれません。
  • 出力ファイル名はとりあえず [スクリプト名].out.txt の固定で、都度上書きされます。

 



$scriptName = $MyInvocation.MyCommand.Name
if ($args.Count -eq 0) {
    echo "Usage: ${scriptName} FILE1 FILE2 ..."
    exit
}

$gInputLists = @()   #ファイルから読み込まれた複数リストの配列
$gOutputLines = @()  # 出力行の配列

function crossjoin {
    param($line, $listIdx, $lineNum)
    if ($listIdx -gt 0) {
        foreach($item in $gInputLists[$listIdx]) {
            $lineNum = crossjoin ([string]::concat($line,",",$item)) ($listIdx-1) $lineNum
        }
    } else {
        foreach($item in $gInputLists[$listIdx]) {
            $gOutputLines[$lineNum] = [string]::concat($line.substring(1),",",$item)
            $lineNum += 1
        }            
    }
    $lineNum
}

$gInputLists = foreach($file in $args) {
    ,(cat $file -Encoding UTF8 | select -Skip 1 )
}

$totalLineCount = $gInputlists|%{$a=1}{$a*=$_.Count}{$a}
$gOutputLines = @("") * $totalLineCount

crossjoin "" ($gInputlists.Count-1) 0 | Out-Null

$result = $gOutputLines -join "`r`n"
[System.IO.File]::WriteAllText(".\${scriptName}.out.txt", $result) # BOMなしUTF-8出力

速度的には VBS 版で約1秒だった 100×100x100 のリストの処理に約3秒かかっていて、ちょっと残念な感じです。

PowerShell の文字列の最大長は VBS よりは余裕があるようで(約20億文字?)で1000万行でもエラーにならず、約30 秒で出力されました。 1億行コースもやってみたのですが、メモリの方が足りなくてエラーになりました。

まとめ

VBS と PowerShell で組み合わせデータを作成するスクリプトを作成しました。

簡単そうなプログラムに見えるかもしれませんが、これでもかなり処理速度の限界に挑戦しています。

そのかいあって、マシン環境にもよりますが、100万行程度の組み合わせ爆発なら、これで実用的な時間内で処理可能になったのではと思います。

さらに実用に合わせて区切り文字や出力ファイル名を変更する修正も難しくないと思います。

お試しください。

本記事は Windows 10 標準搭載の以下の環境で検証しました。

  • Windows Script Host Version 5.812
  • PowerShell Version 5.1.19041.1645

使用上の注意事項

  • 本記事のスクリプトは高速化のためメモリを消費する作りになっていて、組み合わせ爆発で簡単にメモリオーバーとなりますので、注意してください。
  • 不具合等がありましたら、本記事の下のコメントか Twitter にてお知らせください。
  • 本記事のスクリプトを他者と共有する場合には、本記事のURLをお伝えください。セキュリティーの観点から、スクリプトファイルを直接メール等でやり取りするのは避けてください。

【免責】 本記事で公開されたスクリプトプログラムはその機能や動作結果の正確性・正当性について本ブログの管理者が保証するものではありません。 また当プログラムの不具合や誤操作によって利用者に生じたいかなる損害・損失についても、本ブログの管理者は一切の責任を負うことはできませんので、利用に際しては予めご了承ください。 また、本記事のスクリプトプログラムは、あくまで個人の業務効率の改善に資することを目的としています。 使用に際しては出力内容を細心の注意をもって検証し、個人の責任の範囲内でご利用ください。

関連記事

www.shegolab.jp

www.shegolab.jp

www.shegolab.jp

変更履歴

  • [2022/04/23] ソースのインデントが TAB になっていたのをスペースに変換
  • [2022/04/23] 不自然な文面を一部修正

  1. 筆者環境で実測したところ 1,073,741,822 文字まで。