Skip to content

전구길만 걷자

TLDR#

가장 마지막에 오는 단조증가 수열을 단조감소 수열로 변환하는 문제


https://en.cppreference.com/w/cpp/algorithm/next_permutation를 참고했다.

  1. i = len(ls) - 1 ~ 0까지 거꾸로 단조 감소수열이 끝나는 인덱스 i를 찾는다.
  2. upperbound자리를 찾는다. 거꾸로
  3. i..<len(ls) 구간의 원소들을 뒤집는다. 왜냐면 방금 전까지는 단조감소수열이었기 때문.
/**
   * 사전순 상에 다음으로 오는 조합을 collection에 저장한다.
   * 
   * @param ls         out
   * @param comparator 사전순서 정의
   * @return if has no any next permutation, return false, else, return true
   */
  public static <T extends Comparable<T>> 
  boolean nextPermutation(List<T> ls, Comparator<T> comparator) {
    final int lastIdx = ls.size() - 1;
    // 1. 단조감소수열이 끝나는 인덱스 i를 찾는다.
    int i = lastIdx;
    for (; i > 0; --i) {
      if (comparator.compare(ls.get(i - 1), ls.get(i)) < 0)
        break;
    }
    if (i == 0)
      return false;

    // 2. 이진탐색을 활용하여 i - 1에 위치한 원소와 그보다 큰 원소들 중 가장 작은 원소(upperbound)를
    // 찾아 서로 바꾼다. 참고: [i:last]는 현재 reversed이다.
    int lo = i;
    int hi = ls.size();
    T tmp = ls.get(i - 1);
    while (hi - lo > 1) {
      int mid = (lo + hi) / 2;
      if (comparator.compare(ls.get(mid), tmp) <= 0) {
        // go left
        hi = mid - 1;
      } else {
        // go right
        lo = mid;
      }
    }
    ls.set(i - 1, ls.get(lo));
    ls.set(lo, tmp);

    // 3. i번째 원소부터 단조증가수열로 만든다.
    int left = i;
    int right = lastIdx;
    while (left < right) {
      tmp = ls.get(left);
      ls.set(left, ls.get(right));
      ls.set(right, tmp);
      left++;
      right--;
    }

    return true;
  }

boj.kr/17359 문제 해결 소스코드, 시간 순서대로 푼 코드를 지우지 않고 별도의 클래스로 처리했다. https://www.acmicpc.net/source/53386573

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Comparator;
import java.util.List;
import java.util.Stack;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;

public class Main {
  public static void main(String[] args) {
    try (var reader = new BufferedReader(new InputStreamReader(System.in))) {
      int n = Integer.valueOf(reader.readLine());
      List<String> ls = new ArrayList<>(n);
      while (n-- > 0) {
        ls.add(reader.readLine());
      }
      var submit = Solution4.solution(ls);
      System.out.println(submit);
    } catch (IOException e) {
      e.printStackTrace();
      System.exit(2);
    }
  }

  /**
   * Solution ~ Solution3까지는 naive한 코드들, 재귀를 사용함. Solution3을 제외하곤 1,2는 모두 시간초과
   * 발생함.
   * Solution4는 next_permutation 함수를 직접 구현하여 문제를 풂. 약 400ms로 통과함.
   */

  /**
   * Solution 쪽은 재귀를 사용하는데 `current`라고 지금까지 탐색한 인덱스들을 하나씩 붙여놓은 문자열.
   * 재귀 종료조건에 보면 최종 current의 전구상태가 바뀌는 횟수를 리턴하고 있음.
   * 그래서 재귀함수의 리턴 중 최솟값만을 취해 던지면 정답이 된다.
   */
  public static class Solution {
    public static int callCount = 0;

    public static int solution(List<String> ls) {
      BitSet visited = new BitSet(ls.size());
      return recur(ls, visited, "");
    }

    public static int recur(List<String> ls, BitSet visited, String current) {
      callCount++;
      if (visited.stream().count() == ls.size()) {
        return countSwitch(current);
      }
      int min = Integer.MAX_VALUE;
      StringBuilder newStr = new StringBuilder();

      for (int idx = 0; idx < ls.size(); ++idx) {
        newStr.append(current);
        if (visited.get(idx) == false) {
          visited.set(idx);
          newStr.append(ls.get(idx));
          int cnt = recur(ls, visited, newStr.toString());
          min = Math.min(min, cnt);
          visited.clear(idx);
        }
        newStr.delete(0, newStr.length());
      }
      return min;
    }

  }

  /**
   * Solution1이 안되는 이유를 String 붙여넣기에 오버헤드가 발생했다고 가정하여 인덱스만을 저장하고 종료조건에서 한 번에 계산하는
   * 것으로 작성. 이때 굳이 스트링을 다 붙여넣지 말고 경계에 대해서만 계산하고 내부에 발생하는 전구가 바뀌는 경우는 변하지 않으니
   * 미리 계산하는 것으로 변경. 비록 시간초과가 발생했지만 내부적으로 전구가 바뀌는 걸 세는 것은 올바름.
   */
  public static class Solution2 {
    public static int solution(List<String> ls) {
      int constCnt = 0;
      for (int i = 0; i < ls.size(); ++i) {
        constCnt += countSwitch(ls.get(i));
      }
      int boundaryCnt = go(ls, new BitSet(ls.size()), new Stack<Integer>());
      return constCnt + boundaryCnt;
    }

    /** 리스트의 경계에서 발생한 switch만 세서 가장 작은 switch 횟수를 리턴한다. */
    public static int go(List<String> ls, BitSet visited, Stack<Integer> indexOrder) {
      if (visited.stream().count() == ls.size()) {
        int cnt = 0;
        for (int i = 1; i < indexOrder.size(); ++i) {
          String before = ls.get(indexOrder.get(i - 1));
          String after = ls.get(indexOrder.get(i));
          if (before.charAt(before.length() - 1) != after.charAt(0))
            cnt++;
        }
        return cnt;
      }
      int min = Integer.MAX_VALUE;
      for (int idx = 0; idx < ls.size(); ++idx) {
        if (visited.get(idx) == false) {
          visited.set(idx);
          indexOrder.push(idx);

          min = Math.min(min, go(ls, visited, indexOrder));

          visited.clear(idx);
          indexOrder.pop();
        }
      }
      return min;
    }
  }

  /**
   * Solution2의 indexOrder가 분명 시간초과를 발생시켰으리라 생각하고 조금 더 간소화 작업을 수행함. 바로 이전 스트링의 마지막
   * char만 다음 호출시 넘기는 것이다. 왜 lastBoundaryChar의 타입이 String 이냐면, 초기엔 이전 스트링이 없기 때문에
   * 빈 문자열을 넘기기 위해서이다. 그래서 문자열이 비어있는지를 확인하는 부분이 존재한다.
   */
  public static class Solution3 {
    public static int solution(List<String> ls) {
      int constCnt = 0;
      for (int i = 0; i < ls.size(); ++i)
        constCnt += countSwitch(ls.get(i));
      int boundaryCnt = go(ls, new BitSet(ls.size()), "");
      return constCnt + boundaryCnt;
    }

    public static int go(List<String> ls, BitSet visited, String lastBoundaryChar) {
      if (visited.stream().count() == ls.size())
        return 0;
      int min = Integer.MAX_VALUE;
      for (int idx = 0; idx < ls.size(); ++idx) {
        String cur = ls.get(idx);
        if (visited.get(idx) == false) {
          visited.set(idx);

          int cnt = 0;
          if (lastBoundaryChar.length() == 1 &&
              lastBoundaryChar.charAt(0) != cur.charAt(0))
            cnt++;
          cnt += go(ls, visited, String.valueOf(cur.charAt(cur.length() - 1)));
          min = Math.min(min, cnt);

          visited.clear(idx);
        }
      }
      return min;
    }
  }

  /**
   * @kati 코드를 보고 next_permutation을 사용하면 되겠다 판단. nextPermutation 자체가 n!가 아닌 n만큼의
   *       시간복잡도를 사용하기 때문에 이전보다 더 빠른 경우의 수를 가져올 수 있다.
   */
  public static class Solution4 {
    public static int solution(List<String> ls) {
      int constCnt = 0;
      for (int i = 0; i < ls.size(); ++i)
        constCnt += countSwitch(ls.get(i));
      int boundaryCnt = Integer.MAX_VALUE;
      List<Integer> indices = new ArrayList<>(ls.size());
      IntStream.range(0, ls.size()).forEach(indices::add);

      do {
        int cnt = 0;
        for (int i = 1; i < ls.size(); ++i) {
          String left = ls.get(indices.get(i - 1));
          String right = ls.get(indices.get(i));
          if (left.charAt(left.length() - 1) != right.charAt(0))
            cnt++;
        }
        boundaryCnt = Math.min(boundaryCnt, cnt);
      } while (nextPermutation(indices, Comparator.naturalOrder()));
      // first and last

      return constCnt + boundaryCnt;
    }

  }

  /**
   * 전구가 바뀌는 횟수를 리턴한다. 마침 어제 자바책에서 이터레이터를 알려주기에 사용해봤다.
   * forEach문 안에서 일반 int형 변수를 변경하면 effectively final이 아니라서 징징댄다. 그래서
   * AtomicInteger라는 스레드 안전한 개체를 사용했다.
   * 
   * @param bulbs
   * @return
   */
  public static int countSwitch(String bulbs) {
    AtomicInteger cnt = new AtomicInteger(0);
    var iter = bulbs.chars().iterator();
    if (!iter.hasNext())
      return cnt.get();
    AtomicInteger cur = new AtomicInteger(iter.next());
    iter.forEachRemaining((Integer next) -> {
      if (!next.equals(cur.get())) {
        cur.set(next);
        cnt.incrementAndGet();
      }
    });

    return cnt.get();
  }

  /**
   * 사전순 상에 다음으로 오는 조합을 collection에 저장한다.
   * 
   * @param ls         out
   * @param comparator 사전순서 정의
   * @return if has no any next permutation, return false, else, return true
   */
  public static <T extends Comparable<T>> boolean nextPermutation(List<T> ls, Comparator<T> comparator) {
    final int lastIdx = ls.size() - 1;
    // 1. 단조감소수열이 끝나는 인덱스 i를 찾는다.
    int i = lastIdx;
    for (; i > 0; --i) {
      if (comparator.compare(ls.get(i - 1), ls.get(i)) < 0)
        break;
    }
    if (i == 0)
      return false;

    // 2. 이진탐색을 활용하여 i - 1에 위치한 원소와 그보다 큰 원소들 중 가장 작은 원소(upperbound)를
    // 찾아 서로 바꾼다. 참고: [i:last]는 현재 reversed이다.
    int lo = i;
    int hi = ls.size();
    T tmp = ls.get(i - 1);
    while (hi - lo > 1) {
      int mid = (lo + hi) / 2;
      if (comparator.compare(ls.get(mid), tmp) <= 0) {
        // go left
        hi = mid - 1;
      } else {
        // go right
        lo = mid;
      }
    }
    ls.set(i - 1, ls.get(lo));
    ls.set(lo, tmp);

    // 3. i번째 원소부터 단조증가수열로 만든다.
    int left = i;
    int right = lastIdx;
    while (left < right) {
      tmp = ls.get(left);
      ls.set(left, ls.get(right));
      ls.set(right, tmp);
      left++;
      right--;
    }

    return true;
  }

}

예시#

  • [1, 2, 3] 경우의 수 = 3! = 6

    123
    132
    213
    231
    312
    321
    
  • [a, b, c, d] 경우의 수 = 4! = 24

    abcd
    abdc
    acbd
    acdb
    adbc
    adcb
    bacd
    badc
    bcad
    bcda
    bdac
    bdca
    cabd
    cadb
    cbad
    cbda
    cdab
    cdba
    dabc
    dacb
    dbac
    dbca
    dcab
    dcba
    
  • [a, a, b] 경우의 수 = 3! / 2 = 3

    aab
    aba
    baa