베오
DCode
베오
전체 방문자
오늘
어제
  • 분류 전체보기 (218)
    • 공지사항 (1)
    • 잡설 (1)
    • Programming (33)
      • [C] (1)
      • [Java] (4)
      • [Python] (2)
      • [Android] (2)
      • [Network] (0)
      • [Operation System] (2)
      • [Spring Boot] (22)
      • [Docker] (0)
    • Algorithm (31)
      • 자료구조 (2)
      • 알고리즘 (Java) (14)
      • 알고리즘 (기초) (15)
    • Coding Test (131)
      • BOJ (131)
      • Algospat (0)
    • 이론적인거 (14)
      • 보안 (5)
      • 오류 해결 (2)
      • 디자인 패턴 (5)
      • 네트워크 (1)
      • 기타 (1)
    • 최신기술 (4)
      • 블록체인 (1)
    • [Project] (1)

블로그 메뉴

  • 🐈‍⬛ GitHub
  • 📫 방명록
  • 🔖 태그

공지사항

인기 글

티스토리

hELLO · Designed By 정상우.
베오

DCode

문자열 알고리즘
Algorithm/알고리즘 (Java)

문자열 알고리즘

2022. 11. 18. 00:40

용어 정리

  • |S|
    • 문자열 S의 길이
    • S = “abcde” 라면 |S| = 5
  • S[i] (0 ≤ i < |S|)
    • 문자열 S의 i번 글자
    • S = “abcde” 라면 S[3] = d
  • S[i…j]
    • 문자열 S[i] 부터 S[j] 까지 부분 문자열
    • S = “abcde” 라면 S[1…3] = bcd
  • S[…a]
    • 문자열 S[0] 부터 S[a] 까지 부분 문자열
    • S = “abcde” 라면 S[…3] = abcd
  • S[b…]
    • 문자열 S[b] 부터 S[|S|-1] 까지 부분 문자열
    • S = “abcde” 라면 S[3…] = de

문자열 검색

긴 문자열 H가 문자열 N을 부분 문자열로 포함하는지 확인하는 방법

H = “avava” N = “ava” 라면 N이 출현하는 모든 인덱스를 반환하는 알고리즘을 작성한다고 하자

이때 결과값은 0, 2이다.

가장 간단한 방법

N의 가능한 모든 시작 위치를 다 시도해보는 것이다.

다음은 JAVA 코드이다.

import java.util.ArrayList;
import java.util.List;

public class 단순한_문자열_검색_알고리즘_구현 {

    // H의 부분 문자열로 N이 출현하는 시작위치들을 모두 반환한다.
    public static List<Integer> naiveSearch(String H, String N) {
        List<Integer> result = new ArrayList<>();

        // 모든 시작 위치를 다 시도해 본다.
        for (int begin = 0; begin + N.length() <= H.length(); begin++) {
            boolean matched = true;

            // N문자열이 H의 begin 인덱스부터 전부 들어가는 지 체크
            for (int i = 0; i < N.length(); i++) {
                if (H.charAt(begin + i) != N.charAt(i)) {
                    matched = false;
                    break;
                }
            }

            if (matched) {
                result.add(begin);
            }
        }
        return result;
    }

    public static void main(String[] args) {

    }
}

해당 알고리즘을 그림으로 표현하면 다음과 같다.

해당 인덱스의 문자 불일치가 발생하면 바로 다음 인덱스부터 시작한다.

입력이 큰 경우에는 비효율적이지만, 구현이 단순하다는 장점 때문에 표준 라이브러리로 사용된다.

  • C : strstr()
  • C++ : string::find()
  • JAVA : indexOf()

KMP(Knuth-Morris-Pratt) 알고리즘

검색 과정에서 얻은 정보를 이용하여 불필요한 탐색을 줄이는 방법이다.

어떤 긴 문자열에서 N = “aabaabac”를 찾는다고 해보자.

시작 위치 i부터 N을 맞춰 보니 첫 7글자 “aabaaba”는 서로 일치했지만 마지막 문자 ‘c’에서 불일치가 발생했다.

위에서 학습한 단순 알고리즘이라면 i+1부터 시작하겠지만, 위 정보를 바탕으로 불필요한 탐색을 줄여보도록 하자.

위 그림을 보면 i+1에서 시작하는 N은 정답이 될 수 없다. 결국 이를 찾다보면 정답이 될 가능성이 있는 begin은 i+3과 i+6 밖에 없다.

따라서 i를 1씩 증가시키느 것이 아닌 i+3과 i+6만 처리하면 된다.

그래서 시작 위치를 어떻게 찾는데?

우리가 아는 정보는 이전 부분 문자열의 시작 위치, 이전 부분 문자열의 일치하는 문자 수 를 알고있다.

위 그림에서 7글자가 일치하고 다음 글자가 불일치 함을 알 수 있다.

이 때 7글자가 일치했기 때문에, N의 접두사 N[i…7-1]가 H[i…i+7-1]와 일치했음을 알 수 있다.

시작 위치 i+k에서 답을 찾기 위해서 N[…7-1]의 길이 7-k인 접두사와 접미사가 같아야 한다.

따라서 N의 각 접두사에 대해 접두사도 되고 접미사도 되는 문자열의 최대 길이를 계산하면 된다.

접두사도 되고, 접미사도 되는 문자열이 “aaba”, “a” 두가지가 있다. “aaba”를 이용해서 3만큼 이동이 가능하다.

다음을 정의하자

pi[i] = N[…i]의 접두사도 되고 접미사도 되는 문자열의 최대 길이

예를들어 알아보자

N = “aabaabac” 일 때 부분 일치 테이블의 계산

i N[…i] 접두사이면서 접미사인 최대 문자열 pi[i]

0 a (없음) 0
1 aa a 1
2 aab (없음) 0
3 aaba a 1
4 aabaa aa 2
5 aabaab aab 3
6 aabaaba aaba 4
7 aabaabac (없음) 0

matched 글자가 일치한 후 불일치가 발생한다면, 어디부터 시작해야 할까?

정답은 matched-pi[matched-1]만큼 증가시킨 위치이다.

위 표에선 7글자가 일치하므로, 7-pi[6] = 7-4 = 3

즉 3만큼의 인덱스를 증가시키면 된다.

상식적으로 생각해서 N의 aaba의 접두사와 접미사가 같고, 7글자까지 일치하므로, 뒤 4글자를 넘긴 인덱스만큼을 증가시킨다 라고 생각하면 된다.


알고리즘의 구현

static List<Integer> kmpSearch(String H, String N) {
        List<Integer> result = new ArrayList<>();

        // pi[i] = N[..i]의 접미사도 되고, 접두사도 되는 문자열의 최대 길이
        List<Integer> pi = getPartialMatch(N);

        // begin=matched=0 에서 시작
        int begin = 0, matched = 0;
        while (begin <= H.length() - N.length()) {
            if (matched < N.length() && H.charAt(begin + matched) == N.charAt(matched)) {
                matched++;
                // N글자가 모두 일치했다면 답에 추가
                if (matched == N.length()) {
                    result.add(begin);
                }
            } else {
                // matched가 0인 경우에는 다음칸부터 계속
                if (matched == 0) {
                    begin++;
                } else {
                    begin += matched - pi.get(matched - 1);
                    // begin을 옮긴 후에 접두사는 이미 일치함
                    matched = pi.get(matched - 1);
                }
            }
        }

        return result;
    }

시간 복잡도 분석

getPartialMatch()는 무시하고 계산해보자

문자 비교 연산은 각 문자당 한 번 씩 즉 O(|H|)번(begin을 옮긴 후에 접두사는 체크하지 않으므로) 수행된다.

항상 실패한다고 하면, begin은 0부터 |H|-|N|이 될 것이다. (시작 위치가 |H|-|N|을 초과하면 추가로 비교연산을 할 필요가 없다.)

따라서 연산 수행 횟수는 O(|H|)이다.


부분 일치 테이블 생성하기

단순한 문자열 알고리즘을 통해 부분 일치 테이블 생성하기

i = 접미사의 시작 인덱스

static List<Integer> getPartialMatchNaive(String N) {
        List<Integer> pi = new ArrayList<>();
        for (int i = 0; i < N.length(); i++) {
            pi.add(0);
        }

        // i부터 시작하면서
        for (int i = 1; i < N.length(); i++) {
            // j+1글자가 일치한다.
            for (int j = 0; i + j < N.length(); j++) {
                if (N.charAt(i + j) != N.charAt(j)) {
                    break;
                }

                pi.set(i + j, Math.max(pi.get(i + j), j + 1));
            }
        }

        System.out.println(pi);

        return pi;
    }

O(|N|^2)의 시간 복잡도를 가진다.

이 역시 KMP를 이용해서 구할 순 없을까?

KMP 알고리즘으로 부분 일치 테이블 생성하기

static List<Integer> getPartialMatch(String N) {
        // pi[i] = N[..i]의 접미사도 되고, 접두사도 되는 문자열의 최대 길이
        List<Integer> pi = new ArrayList<>();
        for (int i = 0; i < N.length(); i++) {
            pi.add(0);
        }

        // KMP로 자기 자신을 찾는다.
        // N을 N에서 찾는다 begin=0은 처리 안함
        int begin = 1, matched = 0;
        // 비교할 문자가 N의 끝에 도달할 때 까지 찾으면서 부분 일치를 모두 기록한다.
        while (begin + matched < N.length()) {
            if (N.charAt(begin + matched) == N.charAt(matched)) {
                matched++;
                pi.set(begin + matched - 1, matched);
            } else {
                if (matched == 0) {
                    begin++;
                } else {
                    begin += matched - pi.get(matched - 1);
                    matched = pi.get(matched - 1);
                }
            }
        }

        return pi;
    }

왜 begin = 0은 처리를 안하는가?ㅁ

부분 문자열의 길이가 같을 때 ex] abcdefghijklmnop 의 길이의 문자가 있을 때 접두사, 접미사가 abcdefghijklmnop 인 녀석은 제외하기 위함

위 알고리즘 또한 KMP를 사용했으므로, 위에 계산한 시간 복잡도를 대입하여 O(|N|)이 됨을 알 수 있다.

따라서 총 시간 복잡도는 O(|N|+|H|)가 됨을 알 수 있다.


예제: 접두사/접미사 문제(난이도: 하)

긴 문자열 S가 주어질 때 이 문자열의 접두사도 되고 접미사도 되는 부분 문자열의 길이를 모두 출력하라.

S = “ababbaba” 라고 하자.

getPartialMatch를 통해 pi테이블을 만들 수 있다. 이를 통해 알 수 있는 것은 pi[7] = 3임을 알 수 있다. 하지만, “a” 또한 접두사이면서 접미사가 될 수 있다. 이는 어떻게 찾을 것인가?

pi[7]=3 정보를 살펴보자 이는 문자열 전체에서 3글자가 접두사이면서 접미사라는 뜻이다.

그러면 “aba”에서 공통 접두사, 접미사를 찾으면 되는 것이 아닌가?

pi[3]의 정보를 통해 공통 접두사, 접미사의 길이를 찾자.


예제: 팰린드롬 만들기 (난이도: 하)

문자열 S뒤에 적절한 문자열을 붙여서 가장 짧은 펠린드롬을 만들어라.

S의 접미사이면서 S`의 접두사를 찾아내자.

용어 정리

  • |S|
    • 문자열 S의 길이
    • S = “abcde” 라면 |S| = 5
  • S[i] (0 ≤ i < |S|)
    • 문자열 S의 i번 글자
    • S = “abcde” 라면 S[3] = d
  • S[i…j]
    • 문자열 S[i] 부터 S[j] 까지 부분 문자열
    • S = “abcde” 라면 S[1…3] = bcd
  • S[…a]
    • 문자열 S[0] 부터 S[a] 까지 부분 문자열
    • S = “abcde” 라면 S[…3] = abcd
  • S[b…]
    • 문자열 S[b] 부터 S[|S|-1] 까지 부분 문자열
    • S = “abcde” 라면 S[3…] = de

문자열 검색

긴 문자열 H가 문자열 N을 부분 문자열로 포함하는지 확인하는 방법

H = “avava” N = “ava” 라면 N이 출현하는 모든 인덱스를 반환하는 알고리즘을 작성한다고 하자

이때 결과값은 0, 2이다.

가장 간단한 방법

N의 가능한 모든 시작 위치를 다 시도해보는 것이다.

다음은 JAVA 코드이다.

import java.util.ArrayList;
import java.util.List;

public class 단순한_문자열_검색_알고리즘_구현 {

    // H의 부분 문자열로 N이 출현하는 시작위치들을 모두 반환한다.
    public static List<Integer> naiveSearch(String H, String N) {
        List<Integer> result = new ArrayList<>();

        // 모든 시작 위치를 다 시도해 본다.
        for (int begin = 0; begin + N.length() <= H.length(); begin++) {
            boolean matched = true;

            // N문자열이 H의 begin 인덱스부터 전부 들어가는 지 체크
            for (int i = 0; i < N.length(); i++) {
                if (H.charAt(begin + i) != N.charAt(i)) {
                    matched = false;
                    break;
                }
            }

            if (matched) {
                result.add(begin);
            }
        }
        return result;
    }

    public static void main(String[] args) {

    }
}

해당 알고리즘을 그림으로 표현하면 다음과 같다.

해당 인덱스의 문자 불일치가 발생하면 바로 다음 인덱스부터 시작한다.

입력이 큰 경우에는 비효율적이지만, 구현이 단순하다는 장점 때문에 표준 라이브러리로 사용된다.

  • C : strstr()
  • C++ : string::find()
  • JAVA : indexOf()

KMP(Knuth-Morris-Pratt) 알고리즘

검색 과정에서 얻은 정보를 이용하여 불필요한 탐색을 줄이는 방법이다.

어떤 긴 문자열에서 N = “aabaabac”를 찾는다고 해보자.

시작 위치 i부터 N을 맞춰 보니 첫 7글자 “aabaaba”는 서로 일치했지만 마지막 문자 ‘c’에서 불일치가 발생했다.

위에서 학습한 단순 알고리즘이라면 i+1부터 시작하겠지만, 위 정보를 바탕으로 불필요한 탐색을 줄여보도록 하자.

위 그림을 보면 i+1에서 시작하는 N은 정답이 될 수 없다. 결국 이를 찾다보면 정답이 될 가능성이 있는 begin은 i+3과 i+6 밖에 없다.

따라서 i를 1씩 증가시키느 것이 아닌 i+3과 i+6만 처리하면 된다.

그래서 시작 위치를 어떻게 찾는데?

우리가 아는 정보는 이전 부분 문자열의 시작 위치, 이전 부분 문자열의 일치하는 문자 수 를 알고있다.

위 그림에서 7글자가 일치하고 다음 글자가 불일치 함을 알 수 있다.

이 때 7글자가 일치했기 때문에, N의 접두사 N[i…7-1]가 H[i…i+7-1]와 일치했음을 알 수 있다.

시작 위치 i+k에서 답을 찾기 위해서 N[…7-1]의 길이 7-k인 접두사와 접미사가 같아야 한다.

따라서 N의 각 접두사에 대해 접두사도 되고 접미사도 되는 문자열의 최대 길이를 계산하면 된다.

접두사도 되고, 접미사도 되는 문자열이 “aaba”, “a” 두가지가 있다. “aaba”를 이용해서 3만큼 이동이 가능하다.

다음을 정의하자

pi[i] = N[…i]의 접두사도 되고 접미사도 되는 문자열의 최대 길이

예를들어 알아보자

N = “aabaabac” 일 때 부분 일치 테이블의 계산

i N[…i] 접두사이면서 접미사인 최대 문자열 pi[i]

0 a (없음) 0
1 aa a 1
2 aab (없음) 0
3 aaba a 1
4 aabaa aa 2
5 aabaab aab 3
6 aabaaba aaba 4
7 aabaabac (없음) 0

matched 글자가 일치한 후 불일치가 발생한다면, 어디부터 시작해야 할까?

정답은 matched-pi[matched-1]만큼 증가시킨 위치이다.

위 표에선 7글자가 일치하므로, 7-pi[6] = 7-4 = 3

즉 3만큼의 인덱스를 증가시키면 된다.

상식적으로 생각해서 N의 aaba의 접두사와 접미사가 같고, 7글자까지 일치하므로, 뒤 4글자를 넘긴 인덱스만큼을 증가시킨다 라고 생각하면 된다.


알고리즘의 구현

static List<Integer> kmpSearch(String H, String N) {
        List<Integer> result = new ArrayList<>();

        // pi[i] = N[..i]의 접미사도 되고, 접두사도 되는 문자열의 최대 길이
        List<Integer> pi = getPartialMatch(N);

        // begin=matched=0 에서 시작
        int begin = 0, matched = 0;
        while (begin <= H.length() - N.length()) {
            if (matched < N.length() && H.charAt(begin + matched) == N.charAt(matched)) {
                matched++;
                // N글자가 모두 일치했다면 답에 추가
                if (matched == N.length()) {
                    result.add(begin);
                }
            } else {
                // matched가 0인 경우에는 다음칸부터 계속
                if (matched == 0) {
                    begin++;
                } else {
                    begin += matched - pi.get(matched - 1);
                    // begin을 옮긴 후에 접두사는 이미 일치함
                    matched = pi.get(matched - 1);
                }
            }
        }

        return result;
    }

시간 복잡도 분석

getPartialMatch()는 무시하고 계산해보자

문자 비교 연산은 각 문자당 한 번 씩 즉 O(|H|)번(begin을 옮긴 후에 접두사는 체크하지 않으므로) 수행된다.

항상 실패한다고 하면, begin은 0부터 |H|-|N|이 될 것이다. (시작 위치가 |H|-|N|을 초과하면 추가로 비교연산을 할 필요가 없다.)

따라서 연산 수행 횟수는 O(|H|)이다.


부분 일치 테이블 생성하기

단순한 문자열 알고리즘을 통해 부분 일치 테이블 생성하기

i = 접미사의 시작 인덱스

static List<Integer> getPartialMatchNaive(String N) {
        List<Integer> pi = new ArrayList<>();
        for (int i = 0; i < N.length(); i++) {
            pi.add(0);
        }

        // i부터 시작하면서
        for (int i = 1; i < N.length(); i++) {
            // j+1글자가 일치한다.
            for (int j = 0; i + j < N.length(); j++) {
                if (N.charAt(i + j) != N.charAt(j)) {
                    break;
                }

                pi.set(i + j, Math.max(pi.get(i + j), j + 1));
            }
        }

        System.out.println(pi);

        return pi;
    }

O(|N|^2)의 시간 복잡도를 가진다.

이 역시 KMP를 이용해서 구할 순 없을까?

KMP 알고리즘으로 부분 일치 테이블 생성하기

static List<Integer> getPartialMatch(String N) {
        // pi[i] = N[..i]의 접미사도 되고, 접두사도 되는 문자열의 최대 길이
        List<Integer> pi = new ArrayList<>();
        for (int i = 0; i < N.length(); i++) {
            pi.add(0);
        }

        // KMP로 자기 자신을 찾는다.
        // N을 N에서 찾는다 begin=0은 처리 안함
        int begin = 1, matched = 0;
        // 비교할 문자가 N의 끝에 도달할 때 까지 찾으면서 부분 일치를 모두 기록한다.
        while (begin + matched < N.length()) {
            if (N.charAt(begin + matched) == N.charAt(matched)) {
                matched++;
                pi.set(begin + matched - 1, matched);
            } else {
                if (matched == 0) {
                    begin++;
                } else {
                    begin += matched - pi.get(matched - 1);
                    matched = pi.get(matched - 1);
                }
            }
        }

        return pi;
    }

왜 begin = 0은 처리를 안하는가?ㅁ

부분 문자열의 길이가 같을 때 ex] abcdefghijklmnop 의 길이의 문자가 있을 때 접두사, 접미사가 abcdefghijklmnop 인 녀석은 제외하기 위함

위 알고리즘 또한 KMP를 사용했으므로, 위에 계산한 시간 복잡도를 대입하여 O(|N|)이 됨을 알 수 있다.

따라서 총 시간 복잡도는 O(|N|+|H|)가 됨을 알 수 있다.


예제: 접두사/접미사 문제(난이도: 하)

긴 문자열 S가 주어질 때 이 문자열의 접두사도 되고 접미사도 되는 부분 문자열의 길이를 모두 출력하라.

S = “ababbaba” 라고 하자.

getPartialMatch를 통해 pi테이블을 만들 수 있다. 이를 통해 알 수 있는 것은 pi[7] = 3임을 알 수 있다. 하지만, “a” 또한 접두사이면서 접미사가 될 수 있다. 이는 어떻게 찾을 것인가?

pi[7]=3 정보를 살펴보자 이는 문자열 전체에서 3글자가 접두사이면서 접미사라는 뜻이다.

그러면 “aba”에서 공통 접두사, 접미사를 찾으면 되는 것이 아닌가?

pi[3]의 정보를 통해 공통 접두사, 접미사의 길이를 찾자.


예제: 팰린드롬 만들기 (난이도: 하)

문자열 S뒤에 적절한 문자열을 붙여서 가장 짧은 펠린드롬을 만들어라.

S의 접미사이면서 S`의 접두사를 찾아내자.

저작자표시 (새창열림)

'Algorithm > 알고리즘 (Java)' 카테고리의 다른 글

[Java] BFS : 너비 우선 탐색  (0) 2021.12.05
[Java] DFS : 깊이 우선 탐색  (0) 2021.12.01
[Java] 트라이(Trie) 알고리즘  (0) 2021.11.16
[Java] 프림 최소 스패닝 트리  (0) 2021.11.15
[Java] 상호 배타적 집합  (0) 2021.09.23
    'Algorithm/알고리즘 (Java)' 카테고리의 다른 글
    • [Java] BFS : 너비 우선 탐색
    • [Java] DFS : 깊이 우선 탐색
    • [Java] 트라이(Trie) 알고리즘
    • [Java] 프림 최소 스패닝 트리
    베오
    베오

    티스토리툴바