AtCoder PR

AtCoder ABC098C「Attention」をTypeScriptで解く:累積カウントで一撃

AtCoder ABC098C解説
記事内に商品プロモーションを含む場合があります

対象読者

  • AtCoder初級〜中級(ABC常連)
  • アルゴリズムはまだ自信がないけど最短解を身につけたい人
  • TypeScriptで競プロを書いている/書きたい人

問題概要

N 人が一直線に並び、各人は東(E)または西(W)を向いています。任意の 1 人をリーダーに選ぶと、リーダー以外の全員がリーダーの方向を向くように命じます。
向きを変える人数が最小になるようにリーダーを選んだとき、その最小人数を求めてください。

  • 制約: 2 ≤ N ≤ 3×10^5、S は ‘E’ と ‘W’ からなる長さ N の文字列
  • 入出力例:
    入力 :5 WEEWW
    出力 :1
    3 番目(0-indexなら 2)の人をリーダーにすると、向きを変えるのは 1 人だけで最小です。

解き方の全体像

リーダーを位置 i に決めると、

  • **左側(0..i-1)**の人たちはリーダーのいる右を向く=Eを向く必要あり → もともと W の人が裏返る
  • **右側(i+1..N-1)**の人たちは左(=W)を向く必要あり → もともと E の人が裏返る

よって「コスト(裏返る人数)」は
left_W[i] + right_E[i]
で表せます。ここで

  • left_W[i] = 区間 [0, i-1] にある ‘W’ の個数
  • right_E[i] = 区間 [i+1, N-1] にある ‘E’ の個数

これを **累積カウント(prefix/suffix)**で O(N) で作り、全 i の最小値をとれば OK。

ポイント

  • 「累積」というより「累積個数」を使う問題
  • リーダー自身は裏返らない → 数えない(オフバイワン注意)
  • 片側だけ数えれば反対側は全体から差し引けるため、O(1) メモリの片パス解も可能

累積カウントの設計

  • prefixW[i] = 位置 i の左側(0..i-1)の ‘W’ の個数
    • 実装は prefixW[0] = 0 から始め、prefixW[i] = prefixW[i-1] + (S[i-1]==='W'?1:0)
  • suffixE[i] = 位置 i の右側(i+1..N-1)の ‘E’ の個数
    • 実装は suffixE[N-1] = 0 から始め、suffixE[i] = suffixE[i+1] + (S[i+1]==='E'?1:0)

各 i のコストは prefixW[i] + suffixE[i]。最小を答えに。

実装例(TLE)

最初適当に実装したらTLEになりました・・・。
参考にのせておきます。

import * as fs from "fs";

// エントリポイント inputに入力データ全体が入る
function main(input: string): void {
	const [nRaw, S] = input.trim().split(/\r?\n/);
	const N = Number(nRaw);

	let min = Infinity;
	for(let i = 0; i<N; i++){
		if(i === 0) {
			let count = 0;
			for(let j=1; j< S.length; j++){
				if(S[j]==="E") count++; 
			}
			min = Math.min(min, count)
		} else {
			let count = 0;
			for(let j=0; j< i; j++){
				if(S[j]==="W") count++; 
			}
			for(let j=i+1; j< S.length; j++){
				if(S[j]==="E") count++; 
			}
			min = Math.min(min, count)
		}
	} 

	console.log(min);
}

//*この行以降は編集しないでください(標準入出力から一度に読み込み、Mainを呼び出します)
const inputData: string = fs.readFileSync("/dev/stdin", "utf8")
main(inputData);

実装例(TypeScript / Node.js)

// ABC098C - Attention(O(N), 累積カウント)
// 使い方: ts-node main.ts < input.txt

import * as fs from 'fs';

const lines = fs.readFileSync(0, 'utf8').trim().split('\n');
const N = Number(lines[0].trim());
const S = lines.trim();

const prefixW = new Array<number>(N).fill(0); // left_W[i]
for (let i = 1; i < N; i++) {
  prefixW[i] = prefixW[i - 1] + (S[i - 1] === 'W' ? 1 : 0);
}

const suffixE = new Array<number>(N).fill(0); // right_E[i]
for (let i = N - 2; i >= 0; i--) {
  suffixE[i] = suffixE[i + 1] + (S[i + 1] === 'E' ? 1 : 0);
}

let ans = Number.MAX_SAFE_INTEGER;
for (let i = 0; i < N; i++) {
  const cost = prefixW[i] + suffixE[i];
  if (cost < ans) ans = cost;
}

console.log(ans.toString());

例で手計算してみる(WEEWW

  • prefixW = [0,1,1,1,2](各 i の左側にある W の数)
  • suffixE = [2,1,0,0,0](各 i の右側にある E の数)
  • cost(i) =
    • i=0: 0+2=2
    • i=1: 1+1=2
    • i=2: 1+0=1 ← 最小
    • i=3: 1+0=1
    • i=4: 2+0=2

答えは 1。

計算量とメモリ

  • 時間計算量: O(N)
  • 追加メモリ: O(N)(prefixW/suffixE を使う場合)
  • O(1) メモリの片パスでも書けます(次章)

発展的な実装例(片パス・O(1) メモリ)

全体の E の個数 totalE を数えておき、左から走査。
位置 i のとき、

  • 左側の W の個数 = seenW
  • 右側の E の個数 = totalE - seenE - (S[i]==='E' ? 1 : 0)
    • seenE は「これまでに見た E の個数」(位置 i より左)。
    • - (S[i]==='E'?1:0) は「リーダー自身は除外」。
import * as fs from 'fs';

const lines = fs.readFileSync(0, 'utf8').trim().split('\n');
const N = Number(lines[0].trim());
const S = lines.trim();

let totalE = 0;
for (const c of S) if (c === 'E') totalE++;

let seenW = 0;
let seenE = 0;
let ans = Number.MAX_SAFE_INTEGER;

for (let i = 0; i < N; i++) {
  const rightE = totalE - seenE - (S[i] === 'E' ? 1 : 0);
  const cost = seenW + rightE;
  if (cost < ans) ans = cost;

  // update seen counters AFTER using them
  if (S[i] === 'W') seenW++;
  else seenE++;
}

console.log(ans.toString());

よくある間違い

  • リーダー本人を数えてしまう:右側 E を数えるときは i を含めない
  • オフバイワンprefixW[i] は「左側だけ」、suffixE[i] は「右側だけ」
  • 二重ループで O(N^2):全 i で左右を毎回数え直さない
  • 左右の向きの取り違え:左側は E、右側は W に合わせる

まとめ

  • 「左側の W + 右側の E」を各位置で評価するだけの、累積カウント典型
  • 実装は prefixW/suffixE(見通し重視)か、totalE + 片パス(省メモリ)の二択。
  • 制約 3×10^5 に対して O(N) で余裕。競プロの累積パターンの入門に最適。