우아한테크코스 - 프리코스 1주차

주노·2022년 10월 27일
6
post-thumbnail

서론

우아한 테크코스 5기에 지원했다.

프리코스 진행과정을 적어나가려고한다.

첫주차 문제는 알고리즘 문제 형태의 과제가 나왔다.
아마 코딩테스트가 없어지면서 이를 프리코스의 형태로 대신하는 것처럼 생각되었다.

진행 방식에서 구현하기 전에 기능 목록을 만들고 라는 문구에 유독 눈길이 갔다.

코딩테스트라면 일단 주어진 시간내에 빠르고 효율적인 알고리즘을 사용하는것이 중요하겠지만 그러한 방식을 원했다면 그냥 코딩테스트를 진행하는 것이 좋았을 것이다.

우테코가 바라는 성장 방향성이 고민하는 과정을 통해 성장하는것이라고 생각된다.
실제로 사용자의 요구사항을 해결하듯이 문제를 해결해볼까 한다.

🔗GitHub Repository

기능목록 작성

우테코에 참가한 많은 사람들이 기능 목록을 어디에 작성해야할지 고민을 많이 하고있었다.

README.md에 작성하거나 각 문제 페이지에 작성하거나...

협업 및 큰 단위의 기능을 작성할 때는 Swagger같은 기능 명세서의 작성이 필요하겠지만 몇몇 문제의 경우 구현의 Depth가 그리 깊지 않아 주석을 사용하려고한다.

주석(java doc)을 사용하면 메소드 설명을 즉각 확인할 수 있기도 하다.

리팩토링

일주일이라는 시간은 단순히 알고리즘을 이용해 문제를 풀고 마는 정도로 넘기기엔 너무 아까운 시간이다.
이 시간을 내가 작성한 코드를 다른 사람이 읽었을 때 한번에 잘 읽히게끔 가독성 좋은 코드로 리팩토링할 수 있는 기회로 가져가보자.

🚀 Problem 1

💬 요구사항 정리

  • 왼쪽 페이지 번호의 각 자리 숫자를 모두 더하거나, 모두 곱해 가장 큰 수를 구한다.
  • 오른쪽 페이지 번호의 각 자리 숫자를 모두 더하거나, 모두 곱해 가장 큰 수를 구한다.
  • 위 과정에서 가장 큰 수를 본인의 점수로 한다.
  • 점수를 비교해 가장 높은 사람이 게임의 승자가 된다.
  • 승부 결과에 따라 반환하는 결과값은 2,1,0 중 하나다. (단, 예외발생 시 -1)

⛔️ 제한사항

  • pobi와 crong의 길이는 2이다.
  • pobi와 crong에는 [왼쪽 페이지 번호, 오른쪽 페이지 번호]가 순서대로 들어있다.

📚 기능 목록

  1. 숫자의 합을 구하는 기능
  2. 숫자의 곱을 구하는 기능
  3. 점수를 비교하는 기능
  4. 유저의 점수를 구하는 기능
  5. 결과를 구하는 기능

🤔 고찰 및 결과

주어진 예제에서도 확인할 수 있듯이 다음 예외상황을 고려할 수 있다.

pobicrongresult
[97, 98][197, 198]0
[131, 132][211, 212]1
[99, 102][211, 212]-1
  • 책의 왼쪽, 오른쪽 페이지가 옳지 않은 값이 올 수 있다.
    • 책의 왼쪽 페이지는 홀수, 오른쪽 페이지는 짝수
    • (오른쪽페이지) - (왼쪽페이지) = 1

하나의 메소드가 최소한의 기능을 가지게 하도록 메소드를 분리해서 구성했다.

🛠 리팩토링

현재 주석으로 작성되어있는 설명이 한눈에 안들어온다.
javadoc에 대해 블로깅하면서 같이 정리해봤다.

예를들면 다음 문구를 보자

/**
 * 주어진 페이지의 각 자리수 곱을 구한다.
 * @param page 페이지
 * @return 페이지의 각 자리수 곱을 반환한다.
*/

주어진 페이지는 뭐고 곱을 구해서 어떻게 한다는거지? 한눈에 들어오지 않는다.

다음과 같이 고쳐보았다.

/**
 * 주어진 정수의 각 자리수 곱을 반환한다.
 * @param page 책 페이지 번호
 */

반환에 대한 정보를 명시하니 함수의 목적을 읽기 편해졌다.
@return은 중복 서술이니 제거했다.

클린코드를 읽고 난 뒤 주석의 필요성을 느끼지 못해 주석을 모두 삭제하고 함수명에 집중하여 리팩토링을 했다.


🚀 Problem2

💬 요구사항 정리

임의의 문자열 cryptogram이 매개변수로 주어질 때, 연속하는 중복 문자들을 삭제한다.

위 요구사항에서 다음 기능을 생각할 수 있다.

  • 연속된 중복문자가 존재하는지 판별하는 기능
  • 연속된 중복문자를 제거하는 기능

⛔️ 제한사항

  • cryptogram은 길이가 1 이상 1000 이하인 문자열이다.
  • cryptogram은 알파벳 소문자로만 이루어져 있다.

📚 기능 목록

  1. 연속된 중복문자가 존재하는지 판별하는 기능
  2. 연속된 중복문자를 제거하는 기능

🤔 고찰 및 결과

연속된 중복문자를 확인하고 제거하기 위해 정규식을 생각해볼 수 있다.
정규식은 해당 사이트에서 연습해볼 수 있다.

정규식을 짜기 위해 고민을 하게 되었다.

  • \w를 이용하면 하나의 단어를 매칭한다.
  • \w가 2번이상 반복되어 나오는 패턴을 매칭해야한다.

이를 위해서 capturing group에 대해 알아볼 필요가 있다.

괄호로 묶어 해당하는 단어를 뽑아내는 방법이다.
(abc){3} matches abcabcabc. First group matches abc.

단어 하나를 뽑아냈고 이제 중복된 값을 찾아야한다.
위 글에서 빨간색 밑줄친 부분에서 힌트를 얻을 수 있다..!

\1 matches the exact same text that was matched by the first capturing group.

\1은 첫 번째 캡처링 그룹과 일치하는 것과 정확히 동일한 텍스트와 일치합니다.

위 정보를 토대로 (\w)\1을 통해 연속된 2개의 문자를 얻을 수 있다.

하지만 문제는 연속된 2개의 문자가 아닌 연속하는 중복문자들을 제거하므로 2개 이상의 문자를 매칭해야한다.

repetition에 대한 항목을 보면 반복되는 문자를 매칭하기 위해서 +를 사용할 수 있다는 것을 알 수 있다.

따라서 문제를 해결하기 위한 (\w)\1+라는 정규식을 구할 수 있다!

private static boolean checkDuplicate(String cryptogram) {
    Pattern pattern = Pattern.compile("(\\w)\\1+");
    Matcher matcher = pattern.matcher(cryptogram);
    return matcher.find();
}

🛠 리팩토링

Problem 1과 같이 javadoc에 대한 수정이 이루어졌다.


🚀 Problem 3

💬 요구사항 정리

1부터 number까지 3, 6, 9의 개수를 구하라.

⛔️ 제한사항

  • number는 1 이상 10,000 이하인 자연수이다.

📚 기능 목록

  1. 주어진 숫자에서 3,6,9의 개수를 구하는 기능

🤔 고찰 및 결과

위 Problem2에서 그토록 고민했던 정규식을 여기서도 사용할 수 있다!

[^3|6|9]로 3 혹은 6 혹은 9를 제외한 나머지 값을 매칭할 수 있다.

이를 공백으로 바꾼 뒤 그 길이를 구하면 된다.

private static int getClapCount(int number) {
    String str = String.valueOf(number);
    return str.replaceAll("([^3|6|9])", "").length();
}

🛠 리팩토링

Problem 1과 같이 javadoc에 대한 수정이 이루어졌다.


🚀 Problem 4

💬 요구사항 정리

주어진 문자열을 사전을 알파벳 역순으로 배치했을 때 매칭되는 값으로 변환하라.

⛔️ 제한사항

  • word는 길이가 1 이상 1,000 이하인 문자열이다.
  • 알파벳 외의 문자는 변환하지 않는다.
  • 알파벳 대문자는 알파벳 대문자로, 알파벳 소문자는 알파벳 소문자로 변환한다.

📚 기능 목록

  1. 문자를 알파벳 역순으로 매칭하여 변환하는 기능

🤔 고찰 및 결과

컴퓨터공학부라면 들어봤을 ASCII 코드에 대한 내용을 이용하면 해결할 수 있는 문제다.

아스키 코드를 이용하면 임의의 알파벳 대문자가 있을 때 시작지점인 A, 끝점인 Z를 두고 알파벳을 뒤집을 수 있다.

예를들어 알파벳 C가 있다고 할때
'A' = 65, 'Z' = 90, 'C' = 67 이므로
'A(65)' + 'Z(90)' - 'C(67)' = 'X(88)' 이렇게 볼 수 있다.

알파벳 소문자도 마찬가지다.

🛠 리팩토링

Problem 1과 같이 javadoc에 대한 수정이 이루어졌다.


🚀 Problem 5

💬 요구사항 정리

오만 원권, 만 원권, 오천 원권, 천 원권, 오백원 동전, 백원 동전, 오십원 동전, 십원 동전, 일원 동전 각 몇 개로 변환되는지 금액이 큰 순서대로 분류하라.

⛔️ 제한사항

  • money는 1 이상 1,000,000 이하인 자연수이다.

📚 기능 목록

  1. 주어진 수를 각 단위별로 구분하는 기능

🤔 고찰 및 결과

자칫하면 하드코딩의 길로 빠지기 쉬운 문제다.

보기좋은 코드로 작성해보자.

public static List<Integer> solution(int money) {
    List<Integer> answer = Collections.emptyList();
    List<Integer> monetary = List.of(50000, 10000, 5000, 1000, 500, 100, 50, 10, 1);

    for (int i = 0; i < monetary.size(); i++) {
        answer.add(money / monetary.get(i));
        money %= monetary.get(i);
    }
        
    return answer;
}

🛠 리팩토링

아래 for문이 결국 monetary 배열을 모두 순회하므로 forEach로 간추릴 수 있다.

for (int i = 0; i < monetary.size(); i++) {
	answer.add(money / monetary.get(i));
	money %= monetary.get(i);
}

따라서 다음과 같이 수정했다. 줄이 짧아지진 않았지만 의미론적으로 해석 단계가 한단계 줄어들었다.

for (Integer monetary : monetaryList) {
	answer.add(money / monetary);
    money %= monetary;
}

🚀 Problem 6

💬 요구사항 정리

닉네임 중 같은 글자가 연속적으로 포함 되는 닉네임을 판별하고 이를 구분하라.

⛔️ 제한사항

  • 두 글자 이상의 문자가 연속적으로 순서에 맞추어 포함되어 있는 경우 중복으로 간주한다.
  • 크루는 1명 이상 10,000명 이하이다.
  • 이메일은 이메일 형식에 부합하며, 전체 길이는 11자 이상 20자 미만이다.
  • 신청할 수 있는 이메일은 email.com 도메인으로만 제한한다.
  • 닉네임은 한글만 가능하고 전체 길이는 1자 이상 20자 미만이다.
  • result는 이메일에 해당하는 부분의 문자열을 오름차순으로 정렬하고 중복은 제거한다.

📚 기능 목록

  1. 사용자의 이름을 두글자 씩 분리하는 기능
  2. 사용자의 이름 중복을 확인하는 기능
  3. 중복된 이름을 가진 사용자를 저장하는 기능
  4. 결과 목록을 정렬하는 기능

🤔 고찰 및 결과

이번 과제는 구조적으로도 고민을 좀 오래해서 소제목으로 한번 더 분류한다.

Member 객체로 관리하기

forms의 형태가 List<List<String>> 형태라서 해당 데이터를 다룰 때 다소 난잡한 구조를 가지게 된다.
ex) forms.get(index).get(0)

List<Member>로 변환하여 작업을 수행하면 가독성 측면에서 좋은 결과를 기대할 수 있다.

public static class Member {
    private String name;
    private String email;
    private boolean duplicated = false;

    Member() {}
    Member(String name, String email) {}

    public String getEmail() {
        return this.email;
    }

    public String getName() {
        return this.name;
    }

    public boolean isDuplicated() {
        return this.duplicated;
    }

    public void setDuplicated() {
        this.duplicated = true;
    }
}

다음과 같이 forms를 member List로 만들어주자.

List<Member> members = forms.stream()
                .map(form -> new Member(form.get(0), form.get(1)))
                .collect(Collectors.toList());

이제 기능 목록에 명세되어있는 흐름대로 진행하면 된다.

  • 사용자의 이름을 두글자 씩 분리하는 기능
  • 사용자의 이름 중복을 확인하는 기능
  • 중복된 이름을 가진 사용자를 저장하는 기능
  • 결과 목록을 정렬하는 기능

중복을 확인 / 중복사용자 저장하는 기능

private static void checkDuplicateName(String slice, Member member) {
    // 이제엠엠엠엠엠 같은 경우 한 멤버가 동일한 이름을 여럿 가질 경우를 고려해야함.
    if(nameStorage.containsKey(slice) && !member.name.equals(nameStorage.get(slice).getName())) {
        duplicateMemberList.add(member);
        duplicateMemberList.add(nameStorage.get(slice));
    } else {
        nameStorage.put(slice, member);
    }
}

사용자의 이름을 두글자 씩 분리하기

for (Member member : members) {
    String name = member.getName();

    for (int i = 0; i < name.length() - 1; i++) {
        String slice = name.substring(i, i+2);
        checkDuplicateName(slice, member);
    }
}

각 멤버별로 이름을 꺼내어 2글자씩 slice하여 중복검사를 진행한다.

결과 목록을 정렬하기

stream을 이용하여 변환, 정렬을 수행했다.

List<String> answer = duplicateMemberList.stream()
        .map(o -> o.email)
        .sorted()
        .collect(Collectors.toList());

return answer;

🛠 리팩토링

클린코드에 대해 정리하고
함수는 한 가지를 해야 한다. 그 한가지를 잘 해야 한다. 그 한가지만을 해야 한다.
위 문장을 고려하여 함수명과 구조를 수정했다.


🚀 Problem 7

💬 요구사항 정리

미스터코의 친구 추천 규칙에 따라 점수가 가장 높은 순으로 정렬하여 최대 5명을 return 하라.

이때 추천 점수가 0점인 경우 추천하지 않으며, 추천 점수가 같은 경우는 이름순으로 정렬한다.

⛔️ 제한사항

  • user는 길이가 1 이상 30 이하인 문자열이다.
  • friends는 길이가 1 이상 10,000 이하인 리스트/배열이다.
  • friends의 각 원소는 길이가 2인 리스트/배열로 [아이디 A, 아이디 B] 순으로 들어있다.
  • A와 B는 친구라는 의미이다.
  • 아이디는 길이가 1 이상 30 이하인 문자열이다.
  • visitors는 길이가 0 이상 10,000 이하인 리스트/배열이다.
  • 사용자 아이디는 알파벳 소문자로만 이루어져 있다.
  • 동일한 친구 관계가 중복해서 주어지지 않는다.
  • 추천할 친구가 없는 경우는 주어지지 않는다.

📚 기능 목록

  1. 사용자의 친구를 구하는 기능
  2. 함께 아는 친구(위에서 구한 친구)를 가진 사용자를 구하는 기능
  3. 점수를 부여하는 기능
  4. 결과를 정렬하는 기능

🤔 고찰 및 결과

위 6번문제처럼 고민을 조금 더 하게되는 문제다.

이번에는 객체가아닌 흐름을 기준으로 작성해보자.

사전(Dictionary) 작업

사용자의 친구 목록과 다른 사용자들의 이름,점수를 저장하여 활용한다.


/** 사용자의 친구 목록이 저장된다 */
static Set<String> friendDictionary = new HashSet<>();

/** 모든 사용자의 이름,점수가 저장된다 */
static Map<String, Integer> memberDictionary = new HashMap<>();

이로써 함께아는 친구를 확인하기 위해 다음과 같은 방법을 사용할 수 있다.

friends에서 한 쌍의 친구관계를 확인할 때 한쪽이 friendDictionary에 포함되어있는지 확인한다.

점수 구하기

함께아는 친구 1명당 10점


private static void updateScoreByRelationShip(String user, List<String> pair) {
    if (friendDictionary.contains(pair.get(0)) && !Objects.equals(pair.get(1), user)) {
        memberDictionary.put(pair.get(1), memberDictionary.get(pair.get(1)) + 10);
    }

    if (friendDictionary.contains(pair.get(1)) && !Objects.equals(pair.get(0), user)) {
        memberDictionary.put(pair.get(0), memberDictionary.get(pair.get(0)) + 10);
    }
}

방문 횟수마다 1점

/**
 * 방문 이력에 따라 해당 사용자의 점수를 올린다.
 * @param visitor 방문자 목록
 */
private static void updateScoreByVisit(String visitor) {
    memberDictionary.merge(visitor, 1, Integer::sum);
}

처음에는 단순하게 Map.put(key, map.get(key) + 1)을 생각했다.
java의 Map에는 위와같은 기능을 제공하는 merge라는 함수가 있다.
위 값은 map.merge(key, othervalue, (origin, other) -> origin + other)로 해석할 수 있다.

결과값 정제하기

이제 memberDictionary에는 점수가 부여된 사용자들이 존재할 것이다.
정렬하여 결과를 구하면 되는데 이때 제한사항과 예외를 생각해봐야한다.

  • 추천할 친구가 없는경우 (점수가 0점인 경우) 주어지지 않는다
  • 사용자와 이미 친구인 경우 친구추천에 오를 이유가 없다.
/**
 * 결과값을 정제, 정렬, 개수제한을 필터링해서 반환한다.
 * @return 사용자 이메일 List
 */
private static List<String> getResults() {
    return memberDictionary.keySet().stream()
    		// 점수가 0점인 사용자와 이미 친구인 사용자를 제외한다.
            .filter(o -> memberDictionary.get(o) != 0 && !friendDictionary.contains(o))
            // 이름순으로 오름차순 정렬한다.
            .sorted()
            // 점수순으로 오름차순 정렬한다.
            .sorted((o1, o2) -> memberDictionary.get(o2).compareTo(memberDictionary.get(o1)))
            // 최대 5개의 결과로 제한한다.
            .limit(5)
            .collect(Collectors.toList());
}

🛠 리팩토링

클래스 내 전역변수로 사용되는게 뭔가 마음에 들지 않는다
Dictionary 객체를 만들고 함수를 내부에 배치하는 방식으로 수정했다.

public static List<String> solution(String user, List<List<String>> friends, List<String> visitors) {
    Dictionary dictionary = new Dictionary(user, friends);

    dictionary.updateScoreByRelationShip(friends);

    dictionary.updateScoreByVisit(visitors);

    return dictionary.getResult();
}

후기

일주일이라는 시간동안 스스로 작성한 코드를 개선해나가는것이 여간 쉬운일이 아닌 것 같다.

  • 처음에는 javadoc으로 함수마다 기능을 작성해놨는데 막상 쓰고나니 코드명으로도 충분히 기능을 가늠할 수 있음에도 굳이 주석을 달아서 더 난잡해보여서 삭제했다.

  • 1주차가 끝나면 GitHub Discussion을 통해 토론을 할 수 있다고 한다.
    다른 사람의 생각과 의견도 들어보고싶다.
profile
안녕하세요 😆

7개의 댓글

comment-user-thumbnail
2022년 11월 2일

리팩토링 과정까지 상세히 적어주셔서 제 코드를 되돌아볼 수 있었습니다 ㅎㅎ
특히 문제 3번 풀이 방식은 정규표현식을 사용할 생각을 못했었는데.. 많이 배워가요 !!👍🏻

1개의 답글
comment-user-thumbnail
2022년 11월 4일

와 ~ 정리를 정말 잘해주셨네요!! 많이 배워갑니다 ㅎㅎ!

1개의 답글
comment-user-thumbnail
2022년 11월 5일

2번 문제는 예전에 풀어본 문제 유형과 비슷해서 그대로 스택을 활용해서 풀었는데 정규표현식으로 푸는 방법도 있었네요..! 새로운 접근법 잘 배워갑니다!

답글 달기
comment-user-thumbnail
2022년 11월 9일

6,7번 문제 고민이 많았는데 많이 배우고 갑니다~!!

답글 달기