NAKED WETWARE

「わがままになるのが怖い奴に宇宙は拓けねェさ」

Pythonで二次元配列の初期化では「参照渡し」に気をつけないといけない

問題

先日、プログラミングコンテストで、バグチックな変な問題に足元をすくわれました。

それは、初期化した二次元配列の一部の値を更新したら、ほかの値も更新されるというものです。

以下のようなコードです。

# /usr/bin/python3
# coding: utf8

INF = 100010001001

if __name__ == '__main__':
    H, W = 4, 4
    # 二次元配列を準備
    memo = [[INF]*W]*H

    # (0,0)を0で更新
    memo[0][0] = 0

    print(*memo, sep="\n")
    # n行0列がすべて0で更新されてしまっている
    # [0, 100010001001, 100010001001, 100010001001]
    # [0, 100010001001, 100010001001, 100010001001]
    # [0, 100010001001, 100010001001, 100010001001]
    # [0, 100010001001, 100010001001, 100010001001]

原因

どうも、参照渡し系に起因する問題みたいでした。

for文と使って丁寧に初期化すると問題は起きず、ワンライナーで書くと問題が発現します。

    # 手寧な初期化
    memo = []
    for _ in range(H):
        memo.append([INF] * W)
    memo[0][0] = 0
    print(*memo, sep="\n")
    # (0,0)だけが0になってる
    # [0, 100010001001, 100010001001, 100010001001]
    # [100010001001, 100010001001, 100010001001, 100010001001]
    # [100010001001, 100010001001, 100010001001, 100010001001]
    # [100010001001, 100010001001, 100010001001, 100010001001]

    # 繰り返し演算字を使ったワンライナー
    memo_oneliner = [[INF]*W]*H
    memo_oneliner[0][0] = 0
    print(*memo_oneliner, sep="\n")
    # 0列すべて0
    # [0, 100010001001, 100010001001, 100010001001]
    # [0, 100010001001, 100010001001, 100010001001]
    # [0, 100010001001, 100010001001, 100010001001]
    # [0, 100010001001, 100010001001, 100010001001]

参照値(識別値)についてみてみます。

    # 手寧な初期化
    memo = []
    for _ in range(H):
        memo.append([INF] * W)
    for row in memo:
        print(id(row))
    # 識別値は全行で異なる
    # 140091744609096
    # 140091744586440
    # 140091744917000
    # 140091744586952

    # 繰り返し演算字を使ったワンライナー
    memo_oneliner = [[INF]*W]*H
    for row in memo_oneliner:
        print(id(row))
    # 識別値が全行で一致
    # 140091744586504
    # 140091744586504
    # 140091744586504
    # 140091744586504

繰り返し演算子を使うワンライナーのほうでは、全行で識別値が一致しています。

どうもPythonでは、繰り返し演算子を使う場合、新しいオブジェクト(この場合はlistオブジェクト)が都度作成されるわけでなく、 一つ作ったオブジェクトを使いまわすみたいですね。

イメージとしては

    # memo = [[INF]*W]*H のイメージ
    row = [INF]*W
    memo = []
    for _ in range(H):
        memo.append(row)

    # 検証
    memo[0][0] = 0
    print(*memo, sep="\n")
    # [0, 100010001001, 100010001001, 100010001001]
    # [0, 100010001001, 100010001001, 100010001001]
    # [0, 100010001001, 100010001001, 100010001001]
    # [0, 100010001001, 100010001001, 100010001001]
    for row in memo:
        print(id(row))
    # 140503221013320
    # 140503221013320
    # 140503221013320
    # 140503221013320

という感じです。

解決策

多次元配列を一意の値で初期化する場合、丁寧に初期化するのがよさそうです。

そのための、多次元配列初期化関数でも用意しておくのもいいかもしれません。

これでQOLがちょっぴり上がった。

PowerShellの設定

PowerShellの設定

WSLが使えない現場で、bashに似たコンソールはないかと調べてたらPowerShellに行き当たった。

コマンドプロンプトよりもかなり使いやすく、いじればbashにかなり近い使い方ができた。

今回は、そのPowerShellの設定方法をメモしておく。

実行ポリシーの変更

スクリプトなどを実行できるように、実行ポリシーの変更を行う。

管理者権限で実行する必要がある。管理者権限での呼び出し方はいくつかあるけど、ここでは以下の2つを示しておく。

監視者権限での実行(1)

  1. [Windows] + r
  2. 出てきた入力欄に powershell と入力する
  3. Shift + Ctrl + Enterで実行

監視者権限での実行(2)

  1. [Windows] + x, a

実行スクリプト

# 初期ではRestrictedになっている。
> Get-ExecutionPolicy
Restricted

# 実行ポリシーを変更
> Set-ExecutionPolicy RemoteSigned

実行ポリシーの変更
実行ポリシーは、信頼されていないスクリプトからの保護に役立ちます。実行ポリシーを変更すると、about_Execution_Policies
のヘルプ トピック (https://go.microsoft.com/fwlink/?LinkID=135170)
で説明されているセキュリティ上の危険にさらされる可能性があります。実行ポリシーを変更しますか?
[Y] はい(Y)  [A] すべて続行(A)  [N] いいえ(N)  [L] すべて無視(L)  [S] 中断(S)  [?] ヘルプ (既定値は "N"): Y

> Get-ExecutionPolicy
RemoteSigned

> exit

余談だけど、ネットから落としてきたスクリプトなどなZoneIDが付与されて、ローカルのスクリプトと区別されるらしい。

https://www.atmarkit.co.jp/ait/articles/0805/16/news139.html

bashっぽくする

PowerShell拡張機能を提供していたPSReadLineモジュールというのがある。

最近のPowerShellでは、このモジュールがバンドルされている。

このPSReadLineの設定をすることで、bashっぽい動作が可能になる。

# PSReadLineOptionにはEmacsというものがあるので、これを設定する
> Set-PSReadLineOption -EditMode Emacs
# C+iにTabの動作(Complete)を割り当てる
> Set-PSReadLineKeyHandler Ctrl+i Complete

https://dev.classmethod.jp/articles/powershell-core-6-psreadline/

-EditModeは、他にもviモードがあるらしい。

詳細は公式ドキュメント

https://docs.microsoft.com/ja-jp/PowerShell/module/psreadline/Set-PSReadlineOption?view=powershell-6#parameters

Profileの設定

上でやったbashっぽくするフローでbashっぽく使えるようになったけど、毎回同じことをしないといけない。

なので、.bashrc/bash_profileみたいなやつを設定する。

そのために、 Microsoft.PowerShell_profile.ps1 というファイルを用意する

> New-Item -Path $profile -Type file -Force

用意した Microsoft.PowerShell_profile.ps1 というファイル編集する。

Set-PSReadLineOption -EditMode Emacs
Set-PSReadLineKeyHandler Ctrl+i Complete

# Prompt変更
# https://qiita.com/endo_hizumi/items/c5a44036227d1f408aa5 を参考


# llを作成
# http://suyamasoft.blue.coocan.jp/PowerShell/Tips/File/GetChildItem/index.html#GetChildItemwを参考

そもそもこのProfileの意味論的立ち位置がよくわかってないので、何を記載して何を記載すべきでないかもよくわかってない。



これでbashがなくてもそれっぽく使えるコンソール環境を手に入れた。

QOLがちょっぴり上がった。

会社メールをGmailで送受信する方法

SMTP/POP3形式のメールをGmailで送受信できるようにした。

このエントリーは、その手順のまとめ。

TL;DR

  • 設定 > アカウントとインポート > 他のアカウントのメールを確認/メールアカウントを追加する > 後はよしなに
  • 設定 > アカウントとインポート > 名前/他のメールアドレスを追加 > 後はよしなに

発端

会社メールのアカウントをもらった。

しかしこれが、送信にSMTP/受信にPOP3を使うものだった。

Thunderbirdとか使えば、簡単にメールが見られる。

けど、メールはローカルPCに溜まってしまい、出先で確認することができなくなる。

どうにか、WebベースでSMTP/POP3でメールを扱えないか探してみたところ、Gmailが使えそうだったので、設定した。

共通手順

メール設定を行うための画面へ遷移する。

設定画面へは、画面の右にある歯車ボタンを押下して、「設定」を選択する。

画像

受信(POP3)側の設定手順

設定画面の「アカウントとインポート」タブを選択したら、 中段下の方にある「他のアカウントのメールを確認」グループの中の「メールアカウントを追加する」を選択する。

画像

新しい画面がポップアップする。

ここで[メールアドレス]とあるテキストボックスに、会社から払い出されたメールアドレスを入力して、「次へ」を押下して次の画面へ進む。

間違っても、POP3サーバのURLとかは入力しない。

画像

「他のアカウントからメールを読み込む(POP3)」にチェックを入れ、「次へ」を押下して次の画面へ進む。

画像

ここで、必要情報を入力する。

このとき、画像中盤にある「メールの取得にセキュリティで保護された接続(SSL)を使用する」について注意が必要。

大体、ここにチェックを入れるのが現代において正しいと思う。

けど、メールサーバによっては、SSL接続に対応してない場合もある。

(ぼくのとこはそうだった)

だから、このチェックは適宜つけたりつけなかったり判断する。

その次の「受信した~」は、会社メールと普段使いGmailがごっちゃににならないようにするためにつける。

最後に入力値を確認して、「アカウントを追加」ボタンを押す。

これで、メール受信については、設定完了。

画像

送信側の設定

ぼくが使ったやつだと、受信メールの設定が完了した時点で、メール送信の設定画面へ遷移したと思う。

遷移しなかった場合は、以下の手順を行う。

遷移した場合は、「自分のメールアドレスを追加」画面から行う。

設定画面の「アカウントとインポート」タブを選択したら、 中段下の方にある「名前」グループの中の「他のメールアドレスを追加」を選択する。

画像

新しい画面がポップアップする。

ここで「名前」と「メールアドレス」を入力する。

次に「エイリアスとして~」の部分にチェックを入れる。

エイリアスにチェックを入れたので、「名前」は、メールアドレスを送信したときに相手側に見えるものとなるみたい。

エイリアスにチェックを入れないと、Gmail側のアドレスが丸見えになると思う。

だから、エイリアスにチェックを入れておいて、ここでは会社から払い出された「名前」っぽいものを入力しておく。

(大体は、漢字で書かれたものか、アルファベットで書かれてものだと思う)

入力が終わったら「次のステップ」を押下して、次の画面へ進む。

画像

会社からもらった情報を入力して、「アカウント追加」を押下して次の画面へ進む。

画像

次に遷移する画面を取り忘れた。

次は、「自分のメールアドレスを追加」という画面へ遷移する。

Gmailから設定したメールアドレスに変更の通知のメールが飛んでいて、そのメールに記載されているリンクを踏むか、確認コードをこの画面で入力して、設定完了となる。

先に受信設定をしていたので、Gmailの受信ボックスに戻ればお目当てのメールは発見できる。

そのメールを開いて、記載されていてリンクを踏んで、設定は完了。

まとめ

会社メールをスマホでもチェックできるようになり、QOLが上がった。

参考にした資料

【Python】トラップした例外がロギングされない。

TL;DR

Pythonで例外処理、それもエラーメッセージをログ出力するときは、例外クラス名も一緒に出しましょう。

発端

作ったスクリプトが異常終了するという報告を受けた。

聞いた挙動からして、どうもメモリ不足じゃないかと思い、ログを見てみる。

すると、

20YY-MM-DD HH:MM:SS.nnn [INFO  ] hogehoge
20YY-MM-DD HH:MM:SS.nnn [ERROR ] 
20YY-MM-DD HH:MM:SS.nnn [INFO  ] fugafuga

(ログのイメージ)

エラーメッセージが空っぽじゃないですか!

調査

件のスクリプトは、以下のように実装されていた(イメージ)。

def main():
    try:
        hoge_routine()
    except Exception as e:
        log.error(e)
        return 1
    return 0

訳が分からなかったので、トラップした例外を再度投げてみて、コマンドライン上で確かめてみることに。

ちなみに、公式ドキュメント によればシステム系の例外を除いて、すべての例外はExceptionから継承される。

(ユーザ定義例外も、Exceptionから派生させるようにと書いてある)

except節は、例外クラスの派生元をトラップしないけど、派生先はトラップする仕様(ここ)。

だから、except Exception as eで、ほとんどの例外はトラップできる。

def main():
    try:
        hoge_routine()
    except Exception as e:
        log.error(e)
        # return 1
        raise e
    return 0
$ python hoge.py 1> /dev/null 2> err.log

中身(イメージ)。

Traceback (most recent call last):
  ... 中略 ...
MemoryError

ふぁっ!?

エラーメッセージが出ていない。

ここで、例外をNewするとき、コンストラクターに渡す文字列を変えて簡単な実験をしてみる。

ちゃんとした(?)エラーメッセージ付き例外

このコードは、エラーメッセージを標準エラーに出力する

from logging import getLogger

log = getLogger(__name__)

try:
    raise ValueError('associated value: hoge')
except Exception as e:
    log.error(e)
$ python exception1.py 1> stdout.log 2> stderr.log
$ ls -hl std???.log
-rwxr-xr-x 1 user1 user1 23 Mar 21 14:07 stderr.log
-rwxr-xr-x 1 user1 user1  0 Mar 21 14:07 stdout.log
$ cat stderr.log
associated value: hoge
$ 

ちゃんと出てる。

空文字エラーメッセージ付き例外

このコードは、空文字で定義されたエラーメッセージを標準エラーに出力する。

from logging import getLogger

log = getLogger(__name__)

try:
    raise ValueError('')
except Exception as e:
    log.error(e)
$ python exception2.py 1> stdout.log 2> stderr.log
$ ls -hl std???.log
-rwxr-xr-x 1 user1 user1 1 Mar 21 14:11 stderr.log
-rwxr-xr-x 1 user1 user1 0 Mar 21 14:11 stdout.log
$ cat stderr.log
$ 

エラーメッセージがない。

正確には、空のメッセージが出力されている。

先頭改行エラーメッセージ付き例外

このコードは、先頭に改行文字が定義されたエラーメッセージを標準エラーに出力する。

from logging import getLogger

log = getLogger(__name__)

try:
    raise ValueError("\nhoge\nfuga")
except Exception as e:
    log.error(e)
$ python exception3.py 1> stdout.log 2> stderr.log
$ ls -hl std???.log
-rwxr-xr-x 1 user1 user1 24 Mar 21 14:17 stderr.log
-rwxr-xr-x 1 user1 user1  0 Mar 21 14:17 stdout.log
$ cat stderr.log

hoge
fuga
$ 

一応、エラーメッセージは出てる。

原因

今回の例外クラスが持つエラーメッセージ(正確には、「関連値(associated value)」)が空だった。

使用してるモジュールから投げられた例外だし、 普通、関連値って何かしら定義されているものだという認識だった。

けど、そういうことでもないらしい。

対策

この一件で、何もエラーメッセージだけがエラーの情報じゃないことに気が付いた。

例外もクラスとして定義されているのだから、その例外クラス名も有用なエラー情報なのだと。

だから現状、例外クラスのクラス名も同時に出力したほうが、ログとしてはいいのではないかという結論に至った。

def main():
    try:
        hoge_routine()
    except Exception as e:
        log.error(type(e).__name__)  # 例外クラス名を出力
        log.error(e)
        return 1
    return 0

改良されたログ(イメージ)

20YY-MM-DD HH:MM:SS.nnn [INFO  ] hogehoge
20YY-MM-DD HH:MM:SS.nnn [ERROR ] MemoryError
20YY-MM-DD HH:MM:SS.nnn [ERROR ] 
20YY-MM-DD HH:MM:SS.nnn [INFO  ] fugafuga

何のエラーが起きてるか、わかるようになって、QOLがちょっぴり上がった。

【Python/openpyxl】openpyxlで遭遇したMemoryErrorへの対処方法

TL;DR

  • openpyxlはxlsxファイルをメモリ内に展開してあれこれする
  • 書き込み専用モードにすれば、メモリへの展開をいい感じにしてくれる
  • とりまwb = openpyxl.Workbook(write_only=True)を使っとけばOK

環境

Tools Versions
openpyxl 3.0.3
Python 3.6.9

発端

tsvファイルをxlsxファイルに変換するPythonスクリプトを書いた。

実際に動かしてみると、数十万件のデータを扱うと MemoryError 例外が投げられる。

どうやら、一般的なopenpyxlの使い方だと、大量のデータを扱えないようだ。

結果

元処理のイメージは以下。

import openpyxl

def read_date(file_path):
    # ... ファイルからデータを読み込む処理
    # ... 二次元リストで返却
    return data

if __name__ == '__main__':

    # ...

    input_data = read_data(in_file_path)
    wb = openpyxl.Workbook()                          # ☆
    ws = wb.create_sheet(sheet_name)

    for row in input_data:
        data = []
        for col in row:
            cell = openpyxl.cell.Cell(ws, value=col)
            data.append(cell)
        ws.append(data)

    wb.save(out_file_path)
    wb.close()

大量のデータを扱えるように改修したイメージは以下。

lxmlのパッケージが必要みたいだけど、エラーや警告が出たら入れればいいと思う。

import openpyxl

def read_date(file_path):
    # ... ファイルからデータを読み込む処理
    # ... 二次元リストで返却
    return data

if __name__ == '__main__':

    # ...

    input_data = read_data(in_file_path)
    wb = openpyxl.Workbook(write_only=True)                          # ☆ここを書き換えた
    ws = wb.create_sheet(sheet_name)

    for row in input_data:
        data = []
        for col in row:
            cell = openpyxl.cell.Cell(ws, value=col)
            data.append(cell)
        ws.append(data)

    wb.save(out_file_path)
    wb.close()

ここ を読むと、どうやら書き込み専用でWorkbookを開けば問題は解決する模様。

公式ドキュメント によれば、write_only=Trueにすれば、

It is able to export unlimited amount of data (even more than Excel can handle actually), while keeping memory usage under 10Mb.

メモリ使用量を10MB以下に抑えながら、上限なくデータを出力できます。(意訳)

らしい。

他にも公式ドキュメントに記載されている注意点としては以下。

(書き込み専用で)開いたxlsxファイルは、ひとつもシートを持ってないのでcreate_sheetメソッドで作ってやる必要がある。

試してみる。

import openpyxl

wb = openpyxl.Workbook(write_only=True)

print(wb.sheetnames)
# => []

データの書き込みに使えるのは、rowsのappendメソッドだけ。他は使えない。

>>> import openpyxl
>>> wb = openpyxl.Workbook(write_only=True)
>>> ws = wb.create_sheet('hoge')
>>> print(wb.sheetnames)
['hoge']
>>> ws.append([1,2,3])
>>> ws.cell(row=2, col=1).value = 123
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'WriteOnlyWorksheet' object has no attribute 'cell'
>>>
>>> wb_general = openpyxl.Workbook()
>>> ws_general = wb_general.create_sheet('general')
>>> print(type(ws_general).__name__)
Worksheet
>>> print(type(ws).__name__)
WriteOnlyWorksheet

作成したwsインスタンスcellなんてアトリビュートねーですけど、と怒られる。

write_only=Trueを付けるかどうかで、そもそも作成されるWorksheetのクラスが違うらしい。

(書き込み専用で)開いたxlsxファイルは、1回しかsaveメソッドは呼べない。saveメソッドを呼んだ後に再度saveメソッド呼んだり、Worksheetに変更を加えると、例外を飛ばします。

>>> import openpyxl
>>> wb = openpyxl.Workbook(write_only=True)
>>> ws = wb.create_sheet('hoge')
>>> print(wb.sheetnames)
['hoge']
>>> ws.append([1,2,3])
>>> wb.save('hoge.xlsx')
>>> wb.save('hoge.xlsx')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  ... 中略 ...
FileNotFoundError: [Errno 2] No such file or directory: 

発展的話題

公式ドキュメント にある、

It is able to export unlimited amount of data (even more than Excel can handle actually), while keeping memory usage under 10Mb.

メモリ使用量を10MB以下に抑えながら、上限なくデータを出力できます。(意訳)

を信じるならば、Excelでの上限である、

機能 最大数
ワークシートの行と列の合計数 1,048,576 行、16,384 列

(Excel の仕様と制限より作成)

を超えて、Worksheetにデータを書き込めるということでしょうか……。

書き込めたとしても、そのWorkbookはExcelでは二度と開けない気がする。

(今度試してみましょう)

【Python/スニペット】コードリーディングで知ったことをまとめる

個人的なまとめ。

他人のコードを読んでいて勉強になったと感じる部分を記述していきます。

math.ceilを使わない天井関数のような処理

math.ceil(A/B) == (A+B-1)//B == int((A+B-1)/B)

理由

いま、非負整数 q,rに対して A = qB + rとする。

\displaystyle{
\begin{aligned}
\left \lfloor \frac{(A+B-1)}{B} \right \rfloor &= \left \lfloor \frac{(qB+r+B-1)}{B} \right \rfloor \\
 &= \left \lfloor \frac{(q+1)B+r-1)}{B} \right \rfloor \\
 &= \left \lfloor \frac{(q+1)B}{B} + \frac{r-1}{B} \right \rfloor \\
 &=  q + \left \lfloor 1 + \frac{r-1}{B} \right \rfloor
\end{aligned}
}

(1). BがAを割り切るとき、 r = 0 なので、

\displaystyle{
\begin{aligned}
\left \lfloor 1 + \frac{r-1}{B} \right \rfloor &= \left \lfloor 1 - \frac{1}{B} \right \rfloor 
\end{aligned}
}

これより 1 - \frac{1}{B} \lt 1 なので、 \left \lfloor 1 - \frac{1}{B} \right \rfloor = 0

よって、

\displaystyle{
\begin{aligned}
q + \left \lfloor 1 + \frac{r-1}{B} \right \rfloor  &= q + \left \lfloor 1 - \frac{1}{B} \right \rfloor  \\
 &= q + 0 \\
 &= q
\end{aligned}
}

(2). また、BがAを 割り切らない とき、非負整数 r r \lt B なので r-1 \lt Bとなる。

これより、 \frac{r-1}{B} \lt 1なので、

\displaystyle{
\begin{aligned}
q + \left \lfloor 1 + \frac{r-1}{B} \right \rfloor  &= q + 1 + 0 \\
 &= q + 1
\end{aligned}
}

配列を裏返す

# いつも使うやつ (破壊的配列操作)
N = len(arr)
for i in range(N>>1):
    arr[i], arr[-(i+1)] = arr[-(i+1)], arr[i]

# New!
arr2 = arr[::-1]

これは、配列に指定するStepを -1にすることで、「配列をひっくり返す」ということをしてる。

ループ逆回転

Pythonでループを回すときは、いつも昇順にしか回してなかった。

だから降順で回したいときは、インデックスをごにょごにょして頑張ってた。

もっとシンプル(インデックス操作を間違いにくい)方法があるんだと、他人のソースを読んで気が付いた。

# いつもの
N = len(arr)
arr2 = [arr[N-i-1] for i in range(N)]

# New! (1)
N = len(arr)
arr2 = [arr[i] for i in range(N-1,-1,-1)]


# New! (2)
N = len(arr)
arr2 = [arr[i] for i in range(N)[::-1]]

これも、先述の「配列をひっくり返す」と似ている。

パリティチェック

偶奇チェックのこと。

これを書いた人は、きっと物理レイヤー(Cとかアセンブリの世界)に近いところでお仕事してるんじゃないかって思います。

# いつもの
is_odd = True if num % 2 == 1 else Flase
# ちなみに、Pythonでは True == 1 (False == 0)なので以下でも行ける
is_odd = num % 2


# New!
is_odd = (num & 1) == 1
# または
is_odd = num & 1

理由

十進数を二進数表記して考えます。

二進数表記の時、最下位ビット( = 2 ^ 0 )は 0 1を表します。

最下位ビット以外は、すべて 2 ^ nと偶数になってしまうため、偶奇を決定しているのは実質的に最下位ビットです。

最下位ビットとandをとることが、すなわちパリティチェックになります。

言語によっては、コンパイラが最適化の段階で、モジュロ演算をビット演算に変換することもあるようです(wiki)

パフォーマンス

モジュロ演算とビット演算、どちらが速いのか気になったので計測してみた。

ソース

import sys
import os
import re
from math import ceil, floor
from datetime import datetime



if __name__ == '__main__':
    even_cnt = 0
    s = datetime.now()
    for i in range(100000):
        for j in range(1000):
            if j % 2:
                continue
            even_cnt += 1
    e = datetime.now()
    print("Modulo : ", e-s)

    even_cnt = 0
    s = datetime.now()
    for i in range(100000):
        for j in range(1000):
            if j&1:
                continue
            even_cnt += 1
    e = datetime.now()
    print("BitAnd : ", e-s)

結果

Modulo :  0:00:06.186608
BitAnd :  0:00:07.053160

モジュロ演算のほうが速かった。マジか。

なぜ -~n = (n+1) となるのか

Pythonゴルフテク(AtCoder)を読んだ。

目から鱗のテクニックが目白押しだった。

その中で個人的に引っかかった -~n = (n+1)について調べた。

なぜビット反転したものに-1をかけると(n+1)と等しくなるのか

ここが一番の謎だった。

結論から言うと、

ビット反転させたものは、符号が反転し、絶対値が1つ増える性質がある

から。

Pythonでのビット反転(ビットNot)

Pythonは言語仕様的に整数の桁数に制限を設けていない。

そのため、Notの計算を下記のような計算で行っているとのこと。

~n = -(n+1)

(ビットNOT)

もうこれが答えだけれども、もうちょっと噛み砕いてみる。

これはどういうことかというと、10進数の世界でn+1した後に符号を反転させているということ。

2の補数を理解する (1)の「符号付き整数を2の補数によって表現する」にある表が分かりやすい。

2進数表記 符号付き10進数
1111 -1
1110 -2
1101 -3
1100 -4
1011 -5
1010 -6
1001 -7
1000 -8
0111 7
0110 6
0101 5
0100 4
0011 3
0010 2
0001 1
0000 0

(2の補数を理解する (1)をもとに作成)

負数に注目してほしい。

数字が小さくなるほどその絶対値は大きくなっている。

(-1 > -2 だけど |-1| < |-2|になっている)

いま、仮に3に注目して、これを反転させると

3(10) = 0011(2)
# 反転させる
1100(2) = -4

となる。

これが上述の、10進数の世界でn+1した後に符号を反転させている、ということ。

別の言い方をするならば、

符号付2進数の世界では、負数の国と正数の国とがあって、

お互いの国に鏡写しになった奴がいるけど、

その中身はちょうど1だけずれている

といったところだろうか。

3-4をそれぞれ2進数に直すと、

3(10) = 0011(2)-4(10) = 1100(2) となる。

ちょうどビットが反転した形になっているけど、その中身は|-4|-|3|=1 で、ちょうど1だけずれている。

遊んでみる

ビット反転とか2の補数とかで遊んでみる。

# 整数を4桁ビット表記する
def i2b(num):
    mask = 0b1111
    # 参考 : https://teratail.com/questions/81527
    return r'{:04b}'.format(num&mask)

# 実験対象
n = 13  # 1101
        # → 0010 (ビット反転)
        # → 0011 (2の補数表現)
print(i2b(n))  # 1101

# ビット反転
not_n = ~n
print(i2b(not_n))  # 0010
                   # 確かに反転している
print(not_n)  # -14
              # 確かに-(n+1)になっている

# ついでに、2の補数にしてみる
minus_n = not_n + 1
print(i2b(minus_n)) # 0011

# おまけで、元数と2の補数を足してみる
plus_result = n + minus_n
print(i2b(plus_result))  # 0000
                         # 確かに0になった

ビット反転の性質ならば、Python以外でもできるはず。

using System;
using System.Linq;
using System.Collections.Generic;

static class Program {
    static void Main() {
        Int32 n = "01101".FromBit();  
        Console.WriteLine($"~n == -1 * (n+1) => {~n == -1 * (n+1)}");
        Console.WriteLine($"{n} => {n.ToBit(4)}");
        Console.WriteLine($"{~n} => {(~n).ToBit(4)}");
        Console.WriteLine($"{{-~n}} == {{-(-(n+1))}} => {-1*~n==-(-(n+1))}");
        Console.WriteLine($"{{-~n}} - {{n}} => {-~n - n}");
    }

    public static string ToBit(this int n, int digits=4){
        List<char> chList = new List<char>();
        string _ret = Convert.ToString(n,2);
        int len = _ret.Length;
        for(int i=0;i<digits;i++){
            if (i >= len) {
                chList.Add('0');
                continue;
            }
            chList.Add(_ret[(len-1)-i]);
        }
        chList.Reverse();
        return new String(chList.ToArray());
    }

    public static int FromBit(this string bit) {
        int len = bit.Length;
        int ret = 0;
        for(int i=0;i<len;i++){
            ret += (1<<i) * (bit[(len-1)-i] == '1' ? 1 : 0);
        }
        return ret;
    }
}

-~nの正体

ビット反転が「10進数の世界でn+1した後に符号を反転させている」に対応する操作なら、

さらに符号を反転させれば、1だけ増えることになるよね。

あるいは、

-~n = -(~n) = -(-(n+1)) = (n+1)

ということだった模様。