12k
All articles

JavaScriptでテキストの重複行を削除する方法

JavaScriptのSetで重複行を削除し、大文字小文字、空白、空行、改行、最初または最後の行を残す方法を解説します。

OpenReplay Team
OpenReplay Team
JavaScriptでテキストの重複行を削除する方法

JavaScriptで重複行を削除するには、テキストを /\r?\n/ で分割し、各行を Set に通してから再び結合します:[...new Set(text.split(/\r?\n/))].join('\n')。

この処理は、一括インポート前のメールアドレスやIDのリストのクリーニング、ログファイルの整理、2つのCSVエクスポートのマージ、単語リストからの重複除去などで役立ちます。完全に一致する重複であればこのワンライナーで対応できます。結果だけが必要な場合は、重複行削除ツールを使えばブラウザ上で処理できます。大文字・小文字、空白、改行コード、空行だけが異なる「ほぼ重複」した行については、本記事で紹介する関数をそのままコピーして使えます。この関数では、最初の出現を残すか最後の出現を残すかも選択できます。

重要なポイント

  • [...new Set(lines)] は完全に一致する重複行を削除し、各行を最初に出現した位置に残します。これは Set が挿入順に反復処理されるためです。
  • Set は文字列を厳密に比較するため、Apple、apple、apple (末尾にスペースあり)は3つの異なる行として扱われます。
  • 正規化したキー(トリム済み、必要に応じて小文字化)で重複を判定し、出力には元の行を使うことで、大文字・小文字やインデントを保持できます。
  • 既存の Map キーに対して set() を呼び出すと、値は置き換えられますがキーの位置は元のままです。キーを末尾に移動させるには、先に delete() を呼び出します。
  • sort -u は出力をソートするため、元の順序が失われます。awk '!seen[$0]++' は入力順を保持します。

JavaScriptで重複行を削除するワンライナー

JavaScriptで重複行を削除する最も簡潔な方法は、split と join の間に Set を挟むことです:

const dedupeLines = (text) => [...new Set(text.split(/\r?\n/))].join('\n');

dedupeLines('one\nthree\ntwo\nthree\none');
// 'one\nthree\ntwo'

これが機能するのは、Set が値を挿入順に反復処理するためです。各行は最初に出現した位置に残り、それ以降の重複は破棄されます。この順序は ES2015仕様で定義されており、add は新しい値をそれぞれ Set の内部リストの末尾に追加します。機械的に生成されたクリーンな入力であれば、これだけで十分です。Set APIのその他の機能については、JavaScriptのSetメソッドのガイドで解説しています。

単純な Set 版の落とし穴

Set は文字列を厳密に(SameValueZeroを使って)比較するため、1文字でも違いがあれば2つの行は別物として扱われます。以下の各入力では、本来削除したかったはずの行が残ってしまいます:

  • 大文字・小文字: 'Apple\napple' は両方の行が残ります。
  • 末尾の空白: 'apple \napple' は、一方の行がスペースで終わっているため両方残ります。
  • CRLF改行コード: 'apple\r\napple'.split('\n') は ['apple\r', 'apple'] を返します。'\n' だけで分割すると、後ろに改行がない最終行を除くすべての行の末尾に \r が残るため、最終行はそれ以前の同じ内容の行と一致しなくなります。split の区切り文字に正規表現 /\r?\n/ を使えば解決します。旧Mac OS形式の改行コード(単独の \r)を含むテキストが想定される場合は、/\r\n|\r|\n/ を使用してください。
  • 空行: 'a\n\nb\n\nc' は 'a\n\nb\nc' になります。2つ目の空行が1つ目の空行の重複とみなされるため、段落 b と c がつながってしまいます。
  • 末尾の改行: 'a\n\nb\n' は 'a\n\nb' になります。最後の改行の後の空文字列が前の空行と一致し、削除されてしまうためです。

lines.filter((l, i, a) => a.indexOf(l) === i) は避けてください。indexOf は配列を線形探索するため、これを行ごとに実行すると処理全体が O(n²) になります。仕様上、Set の検索は平均して線形時間より高速であることが求められているため、Set を使えばこの再走査を回避できます。また、既出リストとしてプレーンなオブジェクトを使う方法にも固有の問題があります。__proto__ や hasOwnProperty という内容の行があると、正しく動作しなくなる可能性があります。

正規化したキーで重複を判定し、元の行を出力する

ほぼ重複した行を同一とみなすには、正規化したキー(トリムした行、大文字・小文字を区別しない場合はさらに小文字化したもの)を Set と照合し、出力には元の行を追加します。

function removeDuplicateLines(text, options = {}) {
  const {
    caseSensitive = true,
    trim = true,
    collapseWhitespace = false,
    blankLines = 'collapse', // 'keep' | 'collapse' | 'remove'
  } = options;

  const eol = text.includes('\r\n') ? '\r\n' : '\n';
  const trailingNewline = /\r?\n$/.test(text);
  const lines = text.replace(/\r?\n$/, '').split(/\r?\n/);

  const toKey = (line) => {
    let key = trim ? line.trim() : line;
    if (collapseWhitespace) key = key.replace(/\s+/g, ' ');
    return caseSensitive ? key : key.toLowerCase();
  };

  const isBlank = (line) => line.trim() === '';
  const seen = new Set();
  const out = [];

  for (const line of lines) {
    if (isBlank(line)) {
      if (blankLines === 'remove') continue;
      if (blankLines === 'collapse' && out.length && isBlank(out[out.length - 1])) continue;
      out.push(line);
      continue;
    }
    const key = toKey(line);
    if (seen.has(key)) continue;
    seen.add(key);
    out.push(line);
  }

  return out.join(eol) + (trailingNewline ? eol : '');
}

removeDuplicateLines('Apple\napple \n\n\nbanana\nBanana\n', { caseSensitive: false });
// 'Apple\n\nbanana\n'

removeDuplicateLines の各オプションの動作は以下のとおりです:

  • trim は String.prototype.trim を使用します。これはノーブレークスペースや先頭のBOMも除去します。出力では元のインデントが保持されます。
  • caseSensitive: false は toLowerCase を使用します。これはどのロケールでも同じ結果を返します。
  • blankLines: 'keep' は空行を重複判定の対象外とし、元の位置に残します。'collapse' は連続する空行を1つにまとめます。この判定は直前の出力行を見て行うため、間にある重複行が削除された結果として2つの空行が隣接した場合も1つにまとめられます。'remove' はすべての空行を削除します。
  • この関数は入力の末尾の改行を保持します。入力に \r\n が1つでも含まれていれば、出力ではすべての行に \r\n が使われます。改行コードが混在するファイルについては、これは簡略化した扱いです。

キーから行への Map を使っても同様に実装できます:if (!map.has(key)) map.set(key, line)。それぞれのデータ構造がどのような場面に適しているかについては、Map vs Set vs Object を参照してください。Unicode正規化、スマートクォート、その他のペースト時に混入する不要な文字の処理は別のステップで行うべきもので、アプリに貼り付けられたテキストのクリーンアップで解説しています。

最初の出現を残すか、最後の出現を残すか

重複行を削除する際、最初の出現を残す場合と最後の出現を残す場合とでは出力が異なります。さらに、最後の出現を残す方法には、順序が異なる2つのバリエーションがあります。入力 ['apple', 'banana', 'Apple', 'cherry'] を大文字・小文字を区別せずに照合した場合:

戦略結果
最初の出現を優先(キーの Set)apple, banana, cherry
最後の出現を優先、上書きのみ(map.set)Apple, banana, cherry
最後の出現を優先、delete してから setbanana, Apple, cherry
function keepLast(lines, toKey = (l) => l.trim().toLowerCase(), { moveToEnd = true } = {}) {
  const byKey = new Map();
  for (const line of lines) {
    const key = toKey(line);
    if (moveToEnd) byKey.delete(key);
    byKey.set(key, line);
  }
  return [...byKey.values()];
}

Map はキーを最初に追加された順序で保持し、既に存在するキーに対して set() を呼び出すと、キーの位置は変えずに値だけを変更します。そのため、上書きのみを行う処理では、各行の最後のバージョンが返されますが、その位置は行が最初に出現した場所になります。各行を最後に出現した位置に配置するには、set(key, line) の前に delete(key) を呼び出し、キーを末尾に再追加します。

ターミナルで大きなファイルから重複行を削除する

貼り付けるには大きすぎるファイルの場合は、シェルで sort -u または awk を使って重複行を削除します。sort -u はソートによって重複を削除するため、元の順序は失われます。また、現在のロケールの照合順序を使って行を比較するため、C以外のロケールではバイト単位で同一でない行がまとめられてしまう可能性があります。完全一致で比較するには LC_ALL=C sort -u を使用してください。

awk '!seen[$0]++' は各行を初めて出現したときにのみ出力し、入力順を保持します。ただし、一意な行をすべてメモリ上の連想配列に格納します。awk '!seen[tolower($0)]++' は大文字・小文字を区別しません。CRLFのファイルではどちらのツールにも \r が残ったまま渡されるため、事前に tr -d '\r' で除去してください。

LC_ALL=C sort -u input.txt > output.txt
awk '!seen[$0]++' input.txt > output.txt
tr -d '\r' < input.txt | awk '!seen[$0]++' > output.txt

まとめ

完全に一致する重複には new Set を使用します。入力が人の手によるものや複数のソースから集めたものである場合は、正規化したキーで重複を判定し、元の行を出力します。空行の扱いと、最初の出現・最後の出現のどちらを優先するかは、デフォルトに任せず意図的に選択してください。removeDuplicateLines をコピーしてデータに合わせてオプションを設定しましょう。関連する行を削除するのではなくグループ化したい場合は、Object.groupBy を使用してください。

よくある質問

JavaScriptで、ちょうど1回だけ出現する行のみを残すにはどうすればよいですか?

まず出現回数を数えてから、フィルタリングします。1回の走査で行から出現回数への Map を作成し、lines.filter((line) => counts.get(line) === 1) で出現回数が1の行を残します。これは、繰り返し出現する行を1つずつ残す重複削除とは異なります。この方法では、2回以上出現する行は完全に削除されます。ほぼ重複した行を同一の行として数えたい場合は、正規化したキーで集計してください。

あるリストから、別のリストに既に含まれている行を削除するにはどうすればよいですか?

2つ目のリストを Set に読み込み、それを使って1つ目のリストをフィルタリングします:const exclude = new Set(listB); listA.filter((line) => !exclude.has(line))。各チェックは Set の検索で行われるため、行ごとに配列に対して includes や indexOf を呼び出す場合の二乗オーダーのコストを回避できます。事前に両方のテキストを /\r?\n/ で分割し、両方に同じトリムや小文字化のキーを適用してください。

見た目が同じ2つの行が重複削除後も残ってしまうのはなぜですか?

通常、それらの行には異なるUnicodeコードポイントが含まれています。é のようなアクセント付き文字は、1つの合成済み文字として格納される場合と、通常の e の後に結合用アクセント記号が続く形で格納される場合があります。Set はコードユニットを厳密に比較するため、この2つの行を別物として扱います。これらを一致させるには、キー関数に line.normalize('NFC') を追加してください。なお、trim はゼロ幅スペース(U+200B)を除去しないため、別途取り除く必要があります。

Node.jsで、ファイル全体を文字列として読み込まずに大きなファイルから重複行を削除するにはどうすればよいですか?

node:readline モジュールでファイルをストリーム処理し、各行を Set と照合します。createInterface({ input: createReadStream(path), crlfDelay: Infinity }) でインターフェースを作成し、for await でループ処理して、Set にまだ含まれていない行のみを書き出します。crlfDelay: Infinity オプションを指定すると、readline はすべての CRLF を1つの改行として扱います。awk と同様に、メモリ使用量は一意な行の数に応じて増加します。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.