【AtCoder】ABC289 – B問題について解説【C++】

abc289-b プログラミング

そまちょブログのそまちょ(@somachob)です。

この記事は、Sky株式会社プログラミングコンテスト2023(AtCoder Beginner Contest 289)のB問題についての解説です。

A問題については、こちらの記事で解説しています。

競技プログラミングって何?って方はこちらの記事を見てみてください。

B – レ

■問題文

1 から N までの N 個の整数が小さい順に並んでいます。

整数の間に M 個の「レ」が挟まっています。i 個目の「レ」は、整数 ai と整数 ai + 1 の間にあります。

N 個の整数を1回ずつ全て読みます。

  • まず、N 頂点 M 辺の無向グラフ G を考える。i 本目の辺は頂点 ai と頂点 ai + 1 を結んでいる。
  • そして、読まれていない整数がなくなるまで、次の操作を繰り返す。
    • 読まれていない整数のうち最小のものを x として、頂点 x が含まれる連結成分 C を選び、C に含まれる頂点の番号を大きい方から順にすべて読む。
B – レ

例として示されている、N = 5、M = 3、a = (1, 3, 4) では、次のようになります。

それぞれの連結成分について、反転させて出力させます。

abc289-b (1)

最小の x から、どこの整数までつながっているかを考えるのが大事になります。

いろいろな解答をご紹介します。

繰り返しを工夫する

for 文を使った解答コードです。

#include <bits/stdc++.h>
using namespace std;

int main () {
    // 入力
    int n, m;
    cin >> n >> m;
    
    // つながっているかを管理する変数
    vector<bool> v(n + 1, false);
    for(int i = 0; i < m; i++) {
        int a;
        cin >> a;
        v[a] = true;  // つながっている
    }
  
    // 1 から n まで繰り返し
    int left = 1;
    while(left <= n) {
        // 連結成分の一番大きい値
        int right = left;
        // つながっている場合は右にずらす
        while(v[right]) right++;

        // 右の値から左の値まで順に出力
        for(int j = right; j >= left; j--) {
            cout << j << ' ';
        }
      
        // 次の連結成分に移動
        left = right + 1;
    }

    return 0;
}

整数が次の整数とつながっているかを、配列 v で管理しています。

vector は、次のように宣言すれば、すべての要素に同じ値を設定できます。

vector<型> 変数名(要素数, 値)
vector<bool> v(n + 1, false);
for(int i = 0; i < m; i++) {
    int a;
    cin >> a;
    v[a] = true;  // つながっている
}

N = 5、M = 3、a = (1, 3, 4) では、次のようになります。

abc289-b (2)

vector<bool> は、すべての要素が false で初期化されるので、「vector v(n + 1, false);」 とせずに 「vector<bool> v(n + 1);」 でも問題ありません。

出力するためのメインのコードが以下の部分です。

int left = 1;
while(left <= n) {
    // 連結成分の一番大きい値
    int right = left;
    // つながっている場合は右にずらす
    while(v[right]) right++;

    // 右の値から左の値まで順に出力
    for(int j = right; j >= left; j--) {
        cout << j << ' ';
    }
      
    // 次の連結成分に移動
    left = right + 1;
}

N = 5、M = 3、a = (1, 3, 4) では、次のようになります。

配列 v は、要素 (1, 3, 4) が true になるので v = {false, true, false, true, true, false}になります。

abc289-b (3)
abc289-b (4)
abc289-b (5)
abc289-b (6)

Union-Find を使う

グループ分けを効率的に管理できる Union-Find を使った解答コードです。

#include <bits/stdc++.h>
using namespace std;

struct UnionFind {
    // 親の要素とサイズを管理する変数
    vector<int> parent, size;

    // 変数の初期化
    UnionFind(int n) : parent(n, -1), size(n, 1) {}

    // x の根を求める
    int root(int x) {
        // x が根のとき
        if(parent[x] == -1) return x;

        // 経路圧縮
        return parent[x] = root(parent[x]);
    }

    // x と y の根が同じか
    bool isSame(int x, int y) {
        return root(x) == root(y);
    }

    // x と y のグループを併合する
    void unite(int x, int y) {
        // x と y の根を取得
        int rootX = root(x);
        int rootY = root(y);

        // x と y が同じグループのときは何もしない
        if (rootX == rootY) return;

        // union by size( y のサイズが小さくなるように調整 )
        if (size[rootX] < size[rootY]) swap(rootX, rootY);

        // y の親を x にする
        parent[rootY] = rootX;

        // x のサイズに y のサイズを足す
        size[rootX] += size[rootY]; 
    }
  
    // x のサイズを取得
    int getSize(int x) {
      return size[root(x)];
    }
};

int main () {
    // 入力
    int n, m;
    cin >> n >> m;

    // UnionFind の宣言
    UnionFind uf(n + 1);
    for(int i = 0; i < m; i++) {
        int a;
        cin >> a;
        uf.unite(a, a + 1);  // グループを併合する
    }
 
    // 1 から n まで繰り返し
    int left = 1;
    while(left <= n) {
        // 連結成分の一番大きい値
        int right = left + uf.getSize(left) - 1;
        
        // 右の値から左の値まで順に出力
        for(int j = right; j >= left; j--) {
            cout << j << ' ';
        }

        // 次の連結成分に移動
        left = right + 1;
    }
  
    return 0;
}

Union-Find については、こちらの記事で解説しています。

Union-Find を使えば、同じ連結成分かどうか、連結成分のサイズ(要素の数)が簡単に取得できます。

基本的な考え方は、「繰り返しを工夫する」のコードと同じです。

連結成分の一番大きい値を取得する方法が異なります。

Union-Find を使うことで連結成分のサイズを取得して、left に足すことで連結成分の一番大きい値を求めています。

int right = left + uf.getSize(left) - 1;

stack を使う

stack を使った解答コードです。

#include <bits/stdc++.h>
using namespace std;

int main () {
    // 入力
    int n, m;
    cin >> n >> m;
    
    // つながっているかを管理する変数
    vector<bool> v(n + 1, false);
    for(int i = 0; i < m; i++) {
        int a;
        cin >> a;
        v[a] = true;  // つながっている
    }

    stack<int> s;  // 連結成分を積むstack

    // 1 から n まで繰り返し
    for(int i = 1; i <= n; i++) {
        s.push(i);  // stack に積む
      
        if(v[i]) continue;  // 連結されているので次の整数に
        
        // stack が空になるまで繰り返す
        while(!s.empty()) {
            cout << s.top() << ' ';  // 一番上の要素を出力
            s.pop();  // 一番上の要素を取り出す
        }
    }

    return 0;
}

stack は、新しく追加したものを先に取り出すデータ構造です。

top は、一番上の要素をさします。push で、一番上に要素を積みます。popで、一番上の要素を取り出します。

abc289-b (7)
  • S.push(x):S の一番上に要素 x を追加する。
  • S.top():S の一番上の要素を取得する。
  • S.pop():S の一番上の要素を削除する。
  • S.empty():S が空かどうかを返す。

出力するためのメインとなるコードが次の部分です。

// 1 から n まで繰り返し
for(int i = 1; i <= n; i++) {
    s.push(i);  // stack に積む
      
    if(v[i]) continue;  // 連結されているので次の整数に
        
    // stack が空になるまで繰り返す
    while(!s.empty()) {
        cout << s.top() << ' ';  // 一番上の要素を出力
        s.pop();  // 一番上の要素を取り出す
    }
}

N = 5、M = 3、a = (1, 3, 4) では、次のようになります。

abc289-b (8)
abc289-b (9)
abc289-b (10)
abc289-b (11)

反転させる

解説放送での解答コードです。連結成分の区切り位置を管理して、連結成分ごとに反転させます。

#include <bits/stdc++.h>
using namespace std;

int main () {
    // 入力
    int n, m;
    cin >> n >> m;

    // つながっているかを管理する変数
    vector<bool> re(n + 1);
    for(int i = 0; i < m; i++) {
        int a;
        cin >> a;
        re[a] = true;  // つながっている
    }
    
    // 区切り位置を管理する変数
    vector<int> b;
    for(int i = 0; i <= n; i++) {
        if(!re[i]) b.push_back(i);  // 区切り位置を追加
    }
  
    // 答えの変数
    vector<int> answer;
    for(int i = 1; i <= n; i++) answer.push_back(i);  // 1 から n までを代入  

    // 区切位置ごとに反転
    for(int i = 0; i < b.size() - 1; i++) {
        reverse(answer.begin() + b[i], answer.begin() + b[i + 1]);
    }

    // 答えの出力
    for(auto i : answer) cout << i << ' ' ;

    return 0;
}

vector は、push_back(x) を使えば、新しい要素 x を配列の末尾に追加できます。

#include <bits/stdc++.h>
using namespace std;

int main () {
  vector<int> v = {0, 1, 2};
  
  v.push_back(3);  // => 末尾に 3 が追加される。

  // v = {0, 1, 2, 3} になる
  
  return 0;
}

reverse は、要素の並びを反転できます。要素の範囲を指定することで、配列の一部を反転させることもできます。

begin は、配列の先頭のイテレータを取得します。

N = 5、M = 3、a = (1, 3, 4) において、区切り位置と配列 b は、次のようになります。

abc289-b (12)
#include <bits/stdc++.h>
using namespace std;

int main () {
    vector<int> v1 = {1, 2, 3, 4, 5};
    reverse(v1.begin(), v1.end());           // => v1 = {5, 4, 3, 2, 1}
  
    vector<int> v2 = {1, 2, 3, 4, 5};
    reverse(v2.begin(), v2.begin() + 2);     // => v2 = {2, 1, 3, 4, 5}
  
    vector<int> v3 = {1, 2, 3, 4, 5};
    reverse(v3.begin() + 2, v3.begin() + 5); // => v3 = {1, 2, 5, 4, 3}
  
    return 0;
}

答えを出力しているのが次のコードです。範囲 for 文を使って配列 answer の値を出力しています。

// 答えの出力
for(auto i : answer) cout << i << ' ' ;

範囲 for 文は、次のように書きます。auto は、変数の型を推論してくれます。

for (型 要素1つ分の変数名 : 処理したい変数)

再帰関数を使う

再帰関数を使った解答コードです。

#include <bits/stdc++.h>
using namespace std;

vector<bool> v, read;

void solve(int i) {
    if(v[i]) solve(i + 1);  // つながっているときは 1 つ先に
  
    read[i] = true;  // i を読んだことにする
    cout << i << ' ';
}

int main () {
    // 入力
    int n, m;
    cin >> n >> m;

    // つながっているかを管理する変数
    v.assign(n + 1, false);
    for(int i = 0; i < m; i++) {
        int a;
        cin >> a;
        v[a] = true;  // つながっている
    }
  
    // 読んでいるかを管理する変数
    read.assign(n + 1, false);

    // 1 から n まで繰り返し 
    for(int i = 1; i <= n; i++) {
        if(read[i]) continue;  // すでに読んでいる
        
        solve(i);
    }
  
    return 0;
}

solve を再帰関数として実装しています。

void solve(int i) {
    if(v[i]) solve(i + 1);  // つながっているときは 1 つ先に
  
    read[i] = true;  // i を読んだことにする
    cout << i << ' ';
}

つながっているかを管理する変数 v読んだかを管理する read は、main 関数と solve 関数からアクセスしたいので、グローバル変数にしています。

assign は、vector を指定した値で再代入します。

v.assign(n, t); // 要素数 n で、すべての値を t に  

N = 5、M = 3、a = (1, 3, 4) では、次のようになります。

abc289-b (13)
abc289-b (14)
abc289-b (15)
abc289-b (16)

グラフ を使う

深さ優先探索での解答コードです。

#include <bits/stdc++.h>
using namespace std;

using Graph = vector<vector<int>>;
Graph g;
vector<bool> seen;

// 深さ優先探索
void dfs(int v) {
    seen[v] = true;  // v を訪問済みにする
    
    // v から行ける各頂点 next_v について
    for(auto next_v : g[v]) {
        if(seen[next_v]) continue;  // next_v が探索済みなら探索しない
        dfs(next_v);  // 再帰的に探索
    }
  
    cout << v << ' ';
}

int main () {
    // 入力
    int n, m;
    cin >> n >> m;

    // グラフ入力
    g.resize(n + 1);
    for(int i = 0; i < m; i++) {
        int a;
        cin >> a;
        g[a].push_back(a + 1);  // 有向グラフ
    }
  
    // 訪問済みかを管理する変数
    seen.assign(n, false);
  
    // 1 から n まで繰り返し
    for(int i = 1; i <= n; i++) {
        if(seen[i]) continue;  // すでに訪問済み
      
        dfs(i);  // 深さ優先探索
    }
  
    return 0;
}

グラフは、2次元配列でデータを管理します。

using Graph = vector<vector<int>>;
Graph g;

N = 5、M = 3、a = (1, 3, 4) では、次のようになります。

g[1] = {2}
g[2] = {}
g[3] = {4}
g[4] = {5}
g[5] = {}

再帰関数のときのように、グラフ g と訪問済みかを管理する seen はグローバル変数にしています。

resize は、vector の要素数を変更します。

g.resize(n);  // 要素数 n に変更する

dfs 関数内では、範囲 for 文を使って繰り返しています。

// v から行ける各頂点 next_v について
for(auto next_v : g[v]) {
    if(seen[next_v]) continue;  // next_v が探索済みなら探索しない
    dfs(next_v);  // 再帰的に探索
}

参考

DFS (深さ優先探索) 超入門! 〜 グラフ・アルゴリズムの世界への入口 〜【前編】 – Qiita

競技プログラミングの勉強におすすめの書籍を紹介します。

タイトルとURLをコピーしました