본문 바로가기

(3)

아이템6 - 불필요한 객체 생성을 피하라

이전글에 이어서 아이템6의 내용을 정리한다. 똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을때가 많다. 재사용은 빠르고 세련됐다. 특히 불변 객체는 언제든 재사용할 수 있다. 다음 코드는 하지 말아야 할 극단적인 예이다. 자세히 살펴보고 절대 따라하지 말자 String s = new String("bikini"); // 따라 하지 말 것! 이 문장은 실행될 때마다 String 인스턴스를 새로 만든다. 완전히 쓸데없는 행위다. 생성자에 넘겨진 "bikini" 자체가 이 생성자로 만들어내려는 String과 기능적으로 완전히 똑같다. 이 문장이 반복문이나 빈번히 호출되는 메서드 안에 있다면 쓸데없는 String인스턴스가 수백만 개 만들어질 수도 있다. 개선된 버전을 보자 String..

Do it! 웹 사이트 따라 만들기

페이스북 '생활코딩' 채널 커뮤니티 페이지에서 Do it! 웹 사이트 따라 만들기 서평단을 모집한다는 글을 보고(안 그래도 최근 프로젝트를 진행하며 프론트 엔드부분에서 막히는 부분이 많았었다, 물론 벡엔드도..) 바로 지원했다. 운이 좋게도 서평단에 발탁이 되었고, 얼마지나지 않아 책을 받아볼 수 있었다. 이전에 배운적이 있었지만, 머릿속에 그 지식이 일단 책의 시작 부분에 HTML, CSS, JS에 대한 기본 지식에 대해서 핵심만 간략하게 설명이 있다. 기본지식이 탄탄한 경우에는 과감히 건너뛰어도 될 것 같았다. (헷갈리거나 잊고 있었던 개념 확립에 많은 도움을 받았다) 최근 HTML의 정보로서의 가치가 계속해서 증가하는 추세라고 한다. (자신의 사이트가 더 많이 노출되기위해서 어떤 요소를 신경써야하는..

Do it! 자료구조와 함께 배우는 알고리즘 입문 - 검색

이 글은 'Do it! 자료구조와 함께 배우는 알고리즘 입문 (자바 편)' 책(이번 포스팅의 경우 chap3)을 보고 정리한 내용입니다. (기본적인 자바 문법을 안다는 가정에서 기본적인 부분(사람마다 다르겠지만...)위주로 정리한다. 3장에서는 데이터 집합에서 원하는 값을 가진 요소를 찾아내는 검색 알고리즘에 대해 살펴본다. 검색과 키 주소록을 검색한다고 가정했을 때, 검색(searching)은 다음과 같은 과정으로 이루어진다. 국적이 한국인 사람을 찾는다 나이가 21세이상 27세 미만인 사람을 찾는다 찾으려는 이름과 가장 비슷한 이름의 사람을 찾는다 위의 검색뿐만 아니라 어떤 검색을 하게 되더라도 특정 항목에 주목한다는 점은 '검색하기'의 공통점이다. 여기서 그 주목하는 항목을 키(key)라고 하면, ..

개발서적/이펙티브 자바

아이템6 - 불필요한 객체 생성을 피하라

728x90

이전글에 이어서 아이템6의 내용을 정리한다. 

똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을때가 많다. 재사용은 빠르고 세련됐다. 특히 불변 객체는 언제든 재사용할 수 있다. 

다음 코드는 하지 말아야 할 극단적인 예이다. 자세히 살펴보고 절대 따라하지 말자

String s = new String("bikini"); // 따라 하지 말 것! 

이 문장은 실행될 때마다 String 인스턴스를 새로 만든다. 완전히 쓸데없는 행위다. 생성자에 넘겨진 "bikini" 자체가 이 생성자로 만들어내려는 String과 기능적으로 완전히 똑같다. 이 문장이 반복문이나 빈번히 호출되는 메서드 안에 있다면 쓸데없는 String인스턴스가 수백만 개 만들어질 수도 있다. 

개선된 버전을 보자 

String s = "bikini";

이 코드는 새로운 인스턴스를 매번 만드는 대신 하나의 String 인스턴스를 사용한다. 심지어 이 방식을 사용한다면 같은 가상 머신 안에서 이와 똑같은 문자열 리터럴을 사용하는 모든 코드가 같은 객체를 재사용함이 보장된다. 

생성자 대신 정적 팩터리 메서드를 제공하는 불변 클래스에서는 정적 팩터리 메서드를 사용해 불필요한 객체 생성을 피할 수 있다. 예컨대 Boolean(String) 생성자 대신 Boolean.valueOf(String) 팩터리 메서드를 사용하는 것이 좋다(그래서 이 생성자는 자바9에서 사용 자제 API로 지정되었다). 생성자는 호출할 때마다 새로운 객체를 만들지만, 팩터리 메서드는 전혀 그렇지 않다. 불변 객체만이 아니라 가변 객체라 해도 사용 중에 변경되지 않을 것임을 안다면 재사용할 수 있다.

생성 비용이 아주 비싼 객체도 더러 있다. 이런 '비싼 객체'가 반복해서 필요하다면 캐싱하여 재사용하길 권한다. 하지만 자신이 만든 객체가 비싼객체인지를 매번 명확히 알 수는 없다. 예를 들어 주어진 문자열이 유효한 로마 숫자인지를 확인하는 메서드를 작성한다고 해보자. 다음은 정규표현식을 활용한 가장 쉬운 해법이다.

static boolean isRomanNumeral(String s) {
    return s.matches("^(?=.)M*(C[MD]D?C{0,3})"
        + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}

이 방식의 문제는 String.matches 메서드를 사용한다는 데 있다. String.matches는 정규표현식으로 문자열 행태를 확인하는 가장 쉬운 방법이지만, 성능이 중요한 상황에서 반복해 사용하기엔 적합하지 않다. 이 메서드가 내부에서 만드는 정규표현식용 Pattern 인스턴스는,한 번 쓰고 버려져서 곧바로 가비지 컬렉션 대상이 된다. Pattern은 입력받은 정규표현식에 해당하는 유한 상태 머신(finite state machine)을 만들기 때문에 인스턴스 생성 비용이 높다.

성능을 개선하려면 필요한 정규표현식을 표현하는 (불변인) Pattern 인스턴스를 클래스 초기화(정적 초기화) 과정에서 직접 생성해 캐싱해두고, 나중에 isRomanNumeral 메서드가 호출될 때마다 이 인스턴스를 재사용한다. 

public class RomanNumerals {
    private static final Pattern ROMAN = Pattern.compile(
        "^(?=.)M*(C[MD]|D?C{0,3}"
        + "(X[CL]|L?X{0,3})(I[XV]|V?{0,3})$");
        
    static boolean isRomanNumeral(String s) {
        return ROMAN.matcher(s).matches();
    }
}

이렇게 개선하면 isRomanNumeral이 빈번히 호출되는 상황에서 성능을 상당히 끌어올릴 수 있다. 성능만 좋아진 것이 아니라 코드도 더 명확해졌다. 개선 전에서는 존재조차 몰랐던 Pattern 인스턴스를 static final 필드로 끄집어내고 이름을 지어주어 코드의 의미가 훨씬 잘 드러난다. 

개선된 isRomanNumeral 방식의 클래스가 초기화된 후 이 메서드를 한 번도 호출하지 않는다면 ROMAN 필드는 쓸데없이 초기화된 꼴이다. isRomanNumeral 메서드가 처음 호출될 때 필드를 초기화하는 지연 초기화로 불필요한 초기화를 없앨 수는 있지만, 권하지는 않는다. 지연 초기화는 코드를 복잡하게 만드는데, 성능은 크게 개선되지 않을 때가 많기 때문이다. 

객체가 불변이라면 재사용해도 안전함이 명백하다. 하지만 훨씬 덜 명확하거나, 심지어 직관에 반대되는 상황도 있다. 어댑터를 생각해보자. 어댑터는 실제 작업은 뒷단 객체에 위임하고, 자신은 제2의 인터페이스 역할을 해주는 객체다. 어댑터는 뒷단 객체만 관리하면 된다. 즉, 뒷단 객체 외에는 관리할 상태가 없으므로 뒷단 객체 하나당 어댑터 하나씩만 만들어지면 충분하다.

Map 인터페이스의 keySet 메서드는 Map 객체 안의 키 전부를 담은 Set 뷰를 반환한다. keySet을 호출할 때마다 새로운 Set 인스턴스가 만들어지리라고 순진하게 생각할 수도 있지만, 사실은 매번 같은 Set 인스턴스를 반환할지도 모른다. 반환된 Set 인스턴스가 일반적으로 가변이더라도 반환된 인스턴스들은 기능적으로 모두 똑같다. 즉, 반환한 객체 중 하나를 수정하면 다른 모든 객체가 따라서 바뀐다. 모두가 똑같은 Map 인스턴스를 대변하기 때문이다. 따라서 keySet이 뷰 객체를 여러 개 만들어도 상관은 없지만, 그럴 필요도 없고 이득도 없다. 

불필요한 객체를 만들어내는 또 다른 예로 오토박싱(auto boxing)을 들 수 있다. 오토박싱은 프로그래머가 기본 타입과 박싱된 기본 타입을 섞어 쓸 때 자동으로 상호 변환해주는 기술이다. 오토박싱은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것은 아니다. 의미상으로는 별다를 것 없지만 성능에서는 그렇지 않다. 다음 메서드를 보자. 모든 양의 정수를 더하여 총합을 구하는 메서드로, long을 사용해 계산하고 있다. 

private static long sum() {
    Long sum = 0L;
    for (long i = 0; i <= Integer.Max_VALUE; i++)
        sum += i;
        
    return sum;
}

이 프로그램이 정확한 답을 내기는 하지만 제대로 구현했을 때보다 훨씬 느리다. 겨우 문자 하나 때문에 말이다. sum 변수를 long이 아닌 Long으로 선언해서 불필요한 Long 인스턴스가 약 231개나 만들어진 것이다(대략, long 타입인 i가 Long 타입인 sum에 더해질 때마다). 단순히 sum의 타입을 long으로만 바꿔주면 성능이 엄청 좋아진다. 박싱된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의하자. 

이번 아이템을 "객체 생성은 비싸니 피해야 한다"로 오해하면 안된다. 특히나 요즘의 JVM에서는 별다른 일을 하지 않는 작은 객체를 생성하고 회수하는 일이 크게 부담되지 않는다. 프로그램의 명확성, 간결성, 기능을 위해서 객체를 추가로 생성하는 것이라면 일반적으로 좋은 일이다.

거꾸로, 아주 무거운 객체가 아닌 다음에야 단순히 객체 생성을 피하고자 여러분만의 객체 풀(pool)을 만들지는 말자. 물론 객체 풀을 만드는 게 나은 예가 있긴 하다. 데이터베이스 연결 같은 경우 생성 비용이 워낙 비싸니 재사용하는편이 낫다. 하지만 일반적으로는 자체 객체 풀은 코드를 헷갈리게 만들고 메모리 사용량을 늘리고 성능을 떨어뜨린다. 요즘 JVM의 가비지 컬렉터는 상당히 잘 최적화되어서 가벼운 객체용을 다룰 때는 직접 만든 객체 풀보다 훨씬 빠르다. 

이번 아이템은 방어적 복사(defensive copy)를 다루는 아이템 50과 대조적이다. 이번 아이템이 "기존 객체를 재사용해야 한다면 새로운 객체를 만들지 마라"라면, 아이템 50은 "새로운 객체를 만들어야 한다면 기존 객체를 재사용하지 마라"다. 방어적 복사가 필요한 상황에서 객체를 재사용했을 때의 피해가, 필요 없는 객체를 반복 생성했을 때의 피해보다 훨씬 크다는 사실을 기억하자. 방어적 복사에 실패하면 언제 터져 나올지 모르는 버그와 보안 구멍으로 이어지지만, 불필요한 객체 생성은 그저 코드 형태와 성능에만 영향을 준다. 

 

728x90
개발서적

Do it! 웹 사이트 따라 만들기

728x90

페이스북 '생활코딩' 채널 커뮤니티 페이지에서 Do it! 웹 사이트 따라 만들기 서평단을 모집한다는 글을 보고(안 그래도 최근 프로젝트를 진행하며 프론트 엔드부분에서 막히는 부분이 많았었다, 물론 벡엔드도..) 바로 지원했다. 운이 좋게도 서평단에 발탁이 되었고, 얼마지나지 않아 책을 받아볼 수 있었다. 

이전에 배운적이 있었지만, 머릿속에 그 지식이  일단 책의 시작 부분에 HTML, CSS, JS에 대한 기본 지식에 대해서 핵심만 간략하게 설명이 있다. 기본지식이 탄탄한 경우에는 과감히 건너뛰어도 될 것 같았다. (헷갈리거나 잊고 있었던 개념 확립에 많은 도움을 받았다) 최근 HTML의 정보로서의 가치가 계속해서 증가하는 추세라고 한다. (자신의 사이트가 더 많이 노출되기위해서 어떤 요소를 신경써야하는지도 간략하게 쓰여있었는데 이론만 배웠을 때에 비해서 더 흥미롭게 봤던 부분이다!) 

meta 태그의 내용은 검색 엔진이 웹 사이트를 파악할 때 기본으로 사용하고 있다고 한다.

CSS에 대한 설명은 다소 헷갈릴 수 있는 요소들을 그림으로 첨부하여서 그 차이점을 확실하게 명시해줘서 그 차이를 '제대로' 인지할 수 있었다. 그리도 용어도 제대로 모르는 것들이 많았는데 각 용어의 명칭과 (비교적 쉬운) 설명을 겸해서 쉽게 읽어나갈 수 있었다. (가상 요소라는 잘 모르는 부분에 대해서도 알 수 있는 기회였다) 또, CSS로 애니메이션을 구현하는 방법에 대해서도 자세히 설명이 되어있다.

자바스크립트와 제이쿼리에 대한 설명 그리고 제이쿼리의 활용적인 면(난 지금까지.... 뭘 쓴 거지?)에서도 사용법이 잘 설명되어있다.

Ajax의 경우 이전부터 공부하겠다고 마음만 먹고 계속 미루고 있었던 부분인데 간략하게 설명되어있다. 

개발도구의 경우 '서브라임 텍스트'라는 무료 편집 프로그램을 추천하고 있다. (그 외에도 웹스톰(WebStorm), 에디트 플러스(EditPlus), 드림위버(Dreamweaver), 인텔리 제이 아이디어(IntelliJ IDEA)) 이전부터 인텔리제이 IDE를 사용하고 있었기에, 서브라임 텍스트를 별도로 다운로드하지 않고, 인텔리제이를 사용했다. 브라우저를 개발할 때는 개발자 도구(F12 , 크롬의 경우 오른쪽 마우스 클릭 -> 페이지 소스보기)의 용도, 크로스 브라우징에 대한 설명과 대처방안 등을 소개한다. 

그리고 현집 웹 퍼블리셔이신 분이 쓰신 책답게(?) '웹 퍼블리셔'가 무엇인지에 대해서도 설명되어있다. 나의 경우에도 웹 퍼블리셔라는 개념을 한 달 전쯤에 알았다. (디자이너 친구가 웹 퍼블리셔를 꿈꾼다는 얘기 덕에 알게 되었다) 간단하게 생각하면 웹 개발자와 웹 디자이너의 중간쯤이라고 생각하면 된다. 하지만 없어서는 안 될 역할이라는 것은 책을 보면 자연스레 알게 될 것이다.

실질적인 1장을 넘어가면 2장부터는 직접 웹 사이트를 처음부터 끝까지 만들어보는 실습을 진행한다. 각 장을 소개하자면

3. 전체 레이아웃 만들기

4. 페이지 이동 효과 만들기

5. 회사 소개 페이지 만들기

6. 도서 소개 페이지 만들기

7. 도서소개 페이지 추가하기

8. FAQ 페이지 만들기

9. Contact Us 페이지 만들기

10. 구글 API로 Contact Us 폼 처리하기 

총 8개의 실습으로 이루어져 있다. 

이전에 웹 페이지를 만들 때, 생각 없이 어떤 기능을 대충 어떻게 넣어야겠다! 식으로 레이아웃을 대충 구성하고 만들다가 나중에 문제가 생겨서 오히려 시간이 더 오래 걸린 경우가 잦았다. 하지만 이 책은 레이아웃을 구성하는 것에 대한 중요성을 강조하고 구조화시켜서 웹 사이트 제작을 더 체계적으로(= 문제가 안 생기도록!!) 만들어가도록 도와준다. 

웹 페이지 제작면에서도 분명 좋은 도서이지만, 13년 차 웹 퍼블리셔의 Know-How가 많이 들어있었고 그런 부분들은 현직자가 아니고서야(감히 예상컨대... 현직자들조차도!!) 알기 힘든 일이고, 웹 퍼블리셔 혹은 프론트 엔드 개발자에게는 꼭 읽는 것을 추천하고 싶었다.(저 같은 벡앤드 개발을 지향하는 개발자에게도 현업상에서 앞에서(=프론트) 어떤 고민과 고충이 있는지 간접적으로나마 체험할 수 있지 않을까(참된 개발자는 개발 능력뿐만 아니라 소통까지도 잘하는 개발자라고 생각하는 1인)하고 생각한다. 적절한 타이밍에 얻게 된 서평단의 기회 그리고 이 책을 볼 수 있게 된 좋은 기회였다고 생각한다. 이지스 퍼블리싱의 Do it! 시리즈는 이것 말고도 'Do it! 자료구조와 함께 배우는 알고리즘 입문(자바 편)'도 공부하고 있는데, 대체적으로 Do it! 시리즈 책들이 잘 만들어진 것 같다. 

728x90
알고리즘

Do it! 자료구조와 함께 배우는 알고리즘 입문 - 검색

728x90

이 글은 'Do it! 자료구조와 함께 배우는 알고리즘 입문 (자바 편)' 책(이번 포스팅의 경우 chap3)을 보고 정리한 내용입니다. (기본적인 자바 문법을 안다는 가정에서 기본적인 부분(사람마다 다르겠지만...)위주로 정리한다.

3장에서는 데이터 집합에서 원하는 값을 가진 요소를 찾아내는 검색 알고리즘에 대해 살펴본다.

검색과 키

주소록을 검색한다고 가정했을 때, 검색(searching)은 다음과 같은 과정으로 이루어진다.

  1. 국적이 한국인 사람을 찾는다
  2. 나이가 21세이상 27세 미만인 사람을 찾는다
  3. 찾으려는 이름과 가장 비슷한 이름의 사람을 찾는다

위의 검색뿐만 아니라 어떤 검색을 하게 되더라도 특정 항목에 주목한다는 점은 '검색하기'의 공통점이다. 여기서 그 주목하는 항목을 키(key)라고 하면, 국적을 검색하는 경우 국적이 키이고 나이를 검색하는 경우 나이가 키 역할을 한다. 데이터가 단순한 정숫값이면 데이터 값을 키 값이라고 생각해도 좋지만 대부분의 경우에서 키는 데이터의 '일부'이다. 그런데 위의 검색과정을 보면 키 값을 아래처럼 지정한다.

  1. 키 값과 일치하도록 지정합니다(한국).
  2. 키 값의 구간을 지정합니다(21세 이상 27세 미만).
  3. 키 값과 비슷하도록 지정합니다(발음이 가장 비슷한 이름).

물론 이런 조건은 하나만 지정하기도 하지만 논리곱이나 논리합을 사용하여 복합해서 지정하기도 한다.

 

배열에서 검색하기

아래는 검색에 대한 세 가지 예로, 이 세가지 검색 기법에서 몇몇은 자료구조에 의존하는 방법이다.

  • 배열 검색
  • 선형 리스트 검색(9장)
  • 이진 검색트리 검색(8장)

이 장에서 학습하는 것은 '배열 검색'이며 다음의 알고리즘을 활용한다.

  1. 선형 검색 : 무작위로 늘어놓은 데이터 모임에서 검색을 수행한다.
  2. 이진 검색 : 일정한 규칙으로 늘어놓은 
  3. 해시법 : 추가, 삭제가 자주 일어나는 데이터 모임에서 아주 빠른 검색을 수행한다.
    · 체인법 : 같은 해시 값의 데이터를 선형 리스트로 연결하는 방법
    · 오픈 주소법 : 데이터를 위한 해시 값이 충돌할 때 재해시하는 방법

데이터 집합이 있을 때 '검색만 하는 경우'라면 검색에 사용할 알고리즘은 시간이 짧은 것을 선택하면 된다. 그러나 데이터 집합에 대한 검색뿐 아니라 데이터의 추가, 삭제 등을 자주하는 경우라면 검색 이외의 작업에 소요되는 비용을 종합적으로 평가하여 알고리즘을 선택해야 한다. (예를 들어 데이터의 추가를 자주하는 경우에는 검색이 빠르더라도 데이터의 추가 비용이 많이 들어가는 알고리즘은 피해야 한다)

따라서 어떤 목적을 이루기 위해 선택할 수 있는 알고리즘이 여러 가지인 경우에는 용도나 목적, 실행 속도, 자료구조 등을 고려하여 알고리즘을 선택해야 한다.

(1) 선형 검색

요소가 직선 모양으로 늘어선 배열에서의 검색은 원하는 키 값을 갖는 요소를 만날 때까지 앞부터 순서대로 요소를 검색하면 되는데, 이것이 선형 검색(linear search) 또는 순차 검색(sequential search)이라는 알고리즘이다. 

성공의 예와 실패의 예를 보면 배열 검색의 종료 조건은 2개임을 알 수 있다. 다음 조건 중 하나라도 성립하면 검색을 종료한다.

  • 조건1. 검색할 값을 발견하지 못하고 배열의 끝을 지나간 경우
  • 조건2. 검색할 값과 같은 요소를 발견한 경우

물론 '조건 1'이 성립: 검색 실패, '조건 2'가 성립: 검색 성공이다. 배열의 요솟수가 n개일 때 조건 1, 2를 판단하는 횟수는 평균 n / 2회이다.

import java.util.Scanner;

public class SeqSearch {
    // 요솟수가 n인 배열 a에서 key와 같은 요소를 선형 검색한다.
    static int seqSearch(int[] a, int n, int key) {
    
   		int i = 0;

        while (true) {
            if (i == n)
                return -1;  // 검색 실패!(-1을 반환)
            if (a[i] == key)
                return i;   // 검색 성공!(인덱스를 반환)
            i++;
        }
        
    }
    
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);

        System.out.println("요솟수: ");
        int num = sc.nextInt();
        int[] x = new int[num];     // 요솟수가 num인 배열

        for (int i = 0; i < num; i++) {
            System.out.print("x[" + i + "]: ");
            x[i] = sc.nextInt();
        }

        System.out.print("검색할 값: ");    // 키 값을 입력
        int ky = sc.nextInt();

        int idx = seqSearch(x, num, ky);    // 배열 x에서 키 값이 ky인 요소를 검색

        if (idx == -1)
            System.out.println("그 값의 요소가 없습니다.");
        else
            System.out.println(ky + "은(는) x[" + idx + "]에 있습니다.");


    }
    
}

메서드 SeqSearch는 배열 a의 처음부터 끝까지 n개의 요소를 대상으로 값이 key인 요소를 선형 검색하고 검색한 요소의 인덱스를 반환한다. 또한 값이 key인 요소가 여러 개 존재할 경우 반환값은 검색 과정에서 처음 발견한 요소의 인덱스가 된다. 값이 key인 요소가 존재하지 않으면 -1을 반환한다. 

배열을 검색할 때 배열 요소의 인덱스를 가리키는 변수는 i이다. i는 0으로 초기화하고 요소를 하나 검색할 때마다 while문이 제어하는 루프 본문의 끝에서 증가시킨다. while문을 빠져나가는 경우는 앞에서 살펴본 종료 조건 가운에 하나가 성립한 경우이다.

  • i == n이 성립하는 경우 (종료 조건1 : 검색 실패이므로 -1을 반환)

  • a[i] == key가 성립하는 경우 (종료 조건2 : 검색 성공이므로 i를 반환)

이때 whie문을 for문으로 수정하면 프로그램은 보다 짧고 간결해진다. (생략)

보초법

선형 검색은 반복할 때마다 다음의 종료 조건 1과 2를 모두 판단한다. 단순한 판단이라고 생각할 수 있지만 '티끌 모아 태산'이라는 말이 있듯이 종료 조건을 검사하는 비용을 결코 무시할 수 없다.

  1. 검색할 값을 발견하지 못 하고 배열의 끝을 지나간 경우

  2. 검색할 값과 같은 요소를 발견한 경우

이 비용을 반(50%)으로 줄이는 방법이 보초법(sentinel method)이다. 

간단하게 말해서 준비해 놓은 데이터의 마지막 요소로 검색할 값을 저장하여 종료 조건 1이 없어도 된다. (보초를 세운다고 생각!) 보초는 반복문에서 종료 판단 횟수를 2회에서 1회로 줄이는 역할을 한다.

package problem.algorithm.chap3;

import java.util.Scanner;

public class SeqSearchSen {

    public int seqSearchSen(int[] a, int n, int key) {
        int i = 0;

        a[n] = key;     // 보초를 추가 <-- 이거 바로 안되는데.. 왜 된다고 한겨 ... 씁

        while (true) {
            if (a[i] == key)    // 검색 성공!
                break;
            i++;
        }
        return i == n ? -1 : i;
    }


    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);

        System.out.print("요솟수 :");
        int num = sc.nextInt();
        int[] x = new int[num + 1]; // 요솟수가 num + 1

        for (int i = 0; i < num; i++) {
            System.out.print("x[" + i + "]: ");
            x[i] = sc.nextInt();
        }

        System.out.print("검색할 값:");     // 키 값을 입력
        int ky = sc.nextInt();

        SeqSearchSen sol = new SeqSearchSen();
        int idx = sol.seqSearchSen(x, num, ky);       // 배열 x에서 값이 ky인 요소를 검색

        if (idx == -1)
            System.out.println("그 값의 요소가 없습니다.");
        else
            System.out.println(ky + "은(는) x[" + idx + "]에 있습니다.");
        
    }
    
}

 

이 프로그램은 종료조건 1이 필요하지 않기 때문에 하나의 if문만 사용했다. 따라서 반복 종료에 대한 판단 횟수는 실제로 절반으로 줄어든다.

이진 검색

이진 검색은 선형 검색보다 좀 더 빠르게 검색할 수 있다는 장점이 있다. 

이진 검색(binary search)은 요소가 오름차순 또는 내림차순으로 정렬된 배열에서 검색하는 알고리즘이다.

아래 그림에서 오름차순으로 정렬된 데이터에서 39를 검색하는 과정을 생각해보자.
먼저 배열의 중앙에 위치한 요소인 a[5](31)부터 검색을 시작한다.

     0                    1                 2                 3                4                5                 6                7                 8               9               10

5 7 15 28 29 31 39 58 68 70 95

 

검색하려는 값인 39는 중앙 요소(a[5])보다 큰 값이다. 그러므로 검색 대상을 뒤쪽의 5개(a[6] ~ a[10])로 좁힐 수 있다. 그런 다음 검색 범위의 중앙에 위치한 요소인 a[8](68)이 원하는 값인지 확인한다.

        0                    1                 2                 3                4               5               6                7                 8               9               10

5 7 15 28 29 31 39 58 68 70 95

 

검색하려는 값인 39보다 큰 값이다. 그러므로 검색 대상을 앞쪾의 2개로 좁힐 수 있다. 이제 검색해야하는 대상은 2개이다. 이때 두 요소의 중앙 요소는 39나 58 중 아무거나 선택해도 상관없지만 여기서는 앞쪽의 값 39를 선택하여 원하는 값인지 확인한다.

     0                    1                 2                 3                4               5                6                7                 8               9               10

5 7 15 28 29 31 39 58 68 70 95

 

39는 원하는 키의 값과 일치하므로 검색 성공이다. 

이러한 n개의 요소가 오름차순으로 늘어선 배열 a에서 키를 이진 검색으로 검색하는 과정을 일반적인 방법으로 표현해본다.
이때 검색 범위의 맨 앞 인덱스를 pl, 맨 끝 인덱스를 pr, 중앙 인덱스를 pc라고 지정한다. 검색을 시작할 때, pl은 0, pr은 n-1, pc는 (n - 1) / 2로 초기화한다. 여기까지 설명한 내용이 첫번째 단계이다. 검색 대상의 범위는 하얀칸이고 검색 대상에서 제외되는 범위는 검정색 칸의 요소이다. 여기서 주목할 점은 이진 검색을 한 단계씩 진행할 때마다 검색 범위가 반으로 좁혀진다는 것이다. 또한 검사한 요소를 하나씩 제외시키는 선형 검색과는 다르게 이진 검색은 검색할 요소가 해당 단계에서 다음에 검색할 범위의 중간 지점으로 단숨에 이동한다.

1. a[pc] < key 일 때

a[pl] ~ a[pc]는 key 보다 작은 것이 분명하므로 검색 대상에서 제외한다. 검색 범위는 중앙요소 a[pc]보다 뒤쪽의 a[pc+1] ~ a[pr]로 좁힌다. 그런 다음 pl의 값을 pc + 1로 업데이트 한다.

2. a[pc] > key 일 때

a[pc] ~ a[pr]은 key보다 큰 것이 분명하므로 검색 대상에서 제외한다. 검색 범위는 중앙요소 a[pc]보다 앞쪽 a[pl] ~ a[pc - 1]로 좁힌다. 그런 다음 pr의 값을 pc - 1로 업데이트한다. 

이 과정을 구현한 프로그램은 아래와 같다. 이진 검색은 검색을 반복할 때마다 검색 범위가 절반이 되므로 검색에 필요한 비교 횟수의 평균값은 log n이다. 검색에 실패한 경우는 [log(n+1)]회, 검색에 성공한 경우는 대략 [logn - 1]회 이다.

import java.util.Scanner;

public class BinSearch {

    public int binSearch(int[] a, int n, int key) {
        int pl = 0;     // 검색 범위의 첫 인덱스
        int pr = n - 1;   // 검색 범위의 끝 인덱스

        do {
            int pc = (pl + pr) / 2;     // 중앙 요소의 인덱스
            if (a[pc] == key)
                return pc;              // 검색 성공!
            else if (a[pc] < key)
                pl = pc + 1;            // 검색 범위를 뒤쪽 절반으로 좁힘
            else
                pr = pc - 1;             // 검색 범위를 앞쪽 절반으로 좁힘
        } while (pl <= pr);

        return -1;                      // 검색 실패!
    }


    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);

        System.out.print("요솟수: ");
        int num = sc.nextInt();
        int[] x = new int[num];         // 요솟수가 num인 배열
        System.out.println("오름차순으로 입력하세요");

        System.out.print("x[0]:");      // 첫 요소 입력
        x[0] = sc.nextInt();

        for (int i = 1; i < num; i++) {
            do {
                System.out.print("x[" + i + "]: ");
                x[i] = sc.nextInt();
            } while (x[i] < x[i - 1]);     // 바로 앞의 요소보다 작으면 다시 입력
        }

        System.out.print("검색할 값: ");        // 키 값을 입력
        int ky = sc.nextInt();

        BinSearch binSearch = new BinSearch();

        int idx = binSearch.binSearch(x, num, ky);     // 배열x에서 키 값이 ky인 요소를 검색

        if (idx == -1)
            System.out.println("그 값의 요소가 없습니다.");
        else
            System.out.println(ky + "은[는] x[" + idx + "]에 있습니다.");
    }
}

이진 검색은 알고리즘은 검색 대상(배열)이 정렬(sort)되어 있음을 가정한다. 따라서 이 프로그램의 색칠된 부분은 사용자가 각 요소의 값을 입력하는 과정에서 바로 앞에 입력한 요소보다 작은 값인 경우에는 다시 입력하게 한다. 

복잡도

프로그램의 실행 속도는 프로그램이 동작하는 하드웨어나 컴파일러 등의 조건에 따라 달라진다.
알고리즘의 성능을 객관적으로 평가하는 기준을 복잡도(complexity)라고 한다.

  • 시간 복잡도(time complexity) : 실행에 필요한 시간을 평가한 것
  • 공간 복잡도(space coplexity) : 기억 영역과 파일 공간이 얼마나 필요한가를 평가한 것

실행 횟수 n에 비례하는 횟수만큼 실행하는 경우의 복잡도를 O(n)으로 표기한다.
선형 검색알고리즘의 복잡도O(n)이다. 

이진 검색시간 복잡도는 O(log n) 이다.

Arrays.binarySearch에 의한 이진 검색

Java는 배열에서 이진 검색을 하는 메서드를 표준 라이브러리로 제공한다. 이진 검색 표준 라이브러리의 메서드로는 java.utils.Arrays 클래스의 binarySearch 메서드가 있다. binarySearch 메서드는 다음과 같은 장점이 있다.

  1. 이진 검색 메서드를 직접 코딩할 필요가 없다.
  2. 모든 자료형 배열에서 검색할 수 있다.

binarySearch 메서드는 오름차순으로 정렬된 배열 a를 가정하고, 키 값이 key인 요소를 이진 검색한다. (자료형에 따라 9가지 방법으로 오버로딩 되어있다)

검색에 성공한 경우

key와 일치하는 요소의 인덱스를 반환한다. 일치하는 요소가 여러 개 있다면 무작위의 인덱스를 반환한다. (맨 앞의 인덱스나 어떤 특정한 인덱스를 반환하는 것이 아니다!

검색에 실패한 경우

반환한 값은 삽입 포인트를 x라고 할 때 (-x - 1)을 반환한다. 삽입 포인트는 검색하기 위해 지정한 key보다 큰 요소 중 첫 번째 요소의 인덱스이다. 만약 배열의 모든 요소가 key보다 작다면 배열의 길이를 삽입 포인트로 정한다. 만약 배열의 모든 요소가 key보다 작다면 배열의 길이삽입 포인트로 정한다.

import java.util.Arrays;
import java.util.Scanner;

public class BinarySearchTester {
    public static void main(String[] args) {
        Scanner stdIn = new Scanner(System.in);

        System.out.print("요솟수 :");
        int num = stdIn.nextInt();
        int[] x = new int[num]; // 요솟수가 num인 배열

        System.out.println("오름차순으로 입력하세요.");

        System.out.print("x[0] :"); // 배열의 첫 요소를 먼저 입력한다.
        x[0] = stdIn.nextInt();

        for(int i = 1; i < num; i++) {
            do {
                System.out.print("x[" + i + "] : ");
                x[i] = stdIn.nextInt();
            } while (x[i] < x[i - 1]); // 바로 앞의 요소보다 작으면 다시 입력한다.
        }

        System.out.print("검색할 값 : "); // 키 값을 입력 받음
        int ky = stdIn.nextInt();

        int idx = Arrays.binarySearch(x, ky); // 배열 x에서 키 값이 ky인 요소를 검색

        if (idx < 0) {
            System.out.println("그 값의 요소가 없습니다.");
        } else {
            System.out.println(ky + "은(는) x[" + idx + "]에 있습니다.");
        }
    }
}

 

기본 자료형 배열에서 binarySearch 메서드로 검색하기

binarySearch 메서드는 int형이나 long형과 같은 기본 자료형 배열에서 이진 검색을 하는 메서드이다. (위의 코드를 참조)

클래스 메서드와 인스턴스 메서드 (보충)

더보기

Java 메서드의 종류는 다음과 같이 두 가지이다.

  1. 인스턴스 메서드(비정적 메서드)
  2. 클래스 메서드(정적 메서드)

간단히 말해서 인스턴스 메서드는 static을 붙이지 않고 선언한 메서드이고, 클래스 메서드는 static을 붙여 선언한 메서드이다. 둘의 차이점은 '메서드가 인스턴스에 포함되는지'의 여부에 있다. 그래서 클래스 메서드는 전체에 대한 처리를 담당하며 인스턴스 메서드와 처리 영역을 구분하기 위해 주로 사용한다. 

클래스 메서드와 마찬가지로 클래스 변수도 인스턴스에 포함되지 않는 변수이다. 또한 인스턴스의 개수와 관계없


객체의 배열에서 검색하기

객체의 배열에서도 검색할 수 있다. Array 클래스의 메서드 중 아래 2개의 메서드로 검색한다.

  1. static int binarySearch(Object[] a, Object key)

    자연 정렬이라는 방법으로 요소의 대소 관계를 판단한다. 따라서 정수 배열, 문자열 배열에서 검색할 때 적당하다.

  2. static <T> int binarySearch(T[] a, T key, Comparator<? Super T> c)

    "자연 순서"가 아닌 순서로 줄지어 있는 배열에서 검색하거나 "자연 순서"를 논리적으로 갖지 않는 클래스 배열에서 검색할 때 알맞다.

(binarySearch 메서드가 전달받는 자료형은 Object이다. Object는 모든 클래스의 최상위 클래스이다. 그래서 어떤 형태의 클래스도 받을 수 있다)

자연 정렬(natural ordering)

더보기

binarySearch 메서드에 배열과 키 값을 전달하는 간단한 방법으로 검색할 수 있는 이유는 String 클래스가 Comparable<T> 인터페이스와 compareTo 메서드를 구현하고 있기 때문이다. 이때 자연 정렬에 대해 정확히 설명하기 위해 자연 정렬이 된 상태와 그렇지 않은 상태를 비교해본다. 

문자열 정렬 자연 정렬

텍스트1.txt
텍스트10.txt
텍스트100.txt
텍스트2.txt
텍스트21.txt

텍스트1.txt
텍스트2.txt
텍스트10.txt
텍스트21.txt
텍스트100.txt

둘 다 '정렬이 되었다'라는 사실에는 문제가 없다. 하지만 문자열 정렬은 말 그대로 자연스럽지 않다. 컴퓨터의 문자열 정렬은 동일한 위치에 있는 문자의 대소 비교를 통해 정렬하기 때문에 왼쪽과 같은 결과가 나온다. 하지만 사람에게는 오른쪽 형태의 정렬이 더 자연스럽다. 이 정렬을 '자연 정렬'이라고 부른다.

 

자신이 직접 만든 클래스 A에 대해서 "자연스러운 순서로 정렬할 필요가 있겠다!"라는 생각이 든다면 다음과 같이 클래스를 정의하면 된다.

/**
 * 자신이 직접 만들 클래스 A에 대해서 '자연 정렬'을 사용자가 원하는대로 정의하는 방법
 * comareTo, equals 메서드를 오버라이한다.
 */
public class A implements Comparable<A> {

    // 필드, 메서드 등

    @Override
    public int compareTo(A o) {
        // this가 o보다 크면 양의 값 반환
        // this가 o보다 작으면 음의 값 반환
        // this가 o와 같으면 0 반환
    }

    @Override
    public boolean equals(Object obj) {
        // this가 obj와 같으면 true 반환
        // this가 c와 같지 않으면 false 반환
    }
}

 

자연 정렬로 정렬되지 않은 배열에서 검색하기 

자연 정렬로 정렬되지 않은 배열에서의 검색은 제네릭 메서드(generic method)로 하면 된다. 제네릭 메서드의 첫 번째 매개변수 a는 검색 대상이고, 두 번째 매개변수 key는 키 값이다. 제네릭 메서드는 자료형에 구애받지 않는다. 따라서 매개변수로 전달하는 자료형은 Integer, String, 신체검사 데이터용 클래스 PhyscData등 어떤 것을 전달해도 좋다.

하지만 배열의 요소가 어떤 순서로 줄지어 있는지, 각 요소의 대소 관계를 어떻게 판단할 것인지에 대해서는 binarySearch 메서드에 알려주어야 한다. 이 정보는 세 번째 매개변수 c에 전달한다. 

static <T> int binarySearch( T[] a, T key, Comparator<? super T> c )
더보기

Comparator<? super T> c

클래스 T(또는 클래스 T의 슈퍼클래스)로 생성한 두 객체의 대소 관계를 판단하기 위한 comparator이다. comparator 안에는compare 메서드가 있다.

compare(o1, o2)  -->   o1 > o2 이면 양수, o1 < o2 이면 음수, o1 == o2 이면 0 을 반환

 

세 번째 매개변수 c에는 comparator를 전달한다. comparator의 근원은 다음과 같이 정의된 java.util.Comparator 인터페이스이다.

// java.util.Comparator의 정의
package.java.util;

public interface Comparator <T> {
	int compare(T o1, T o2);
    boolean equals(Object obj);
}

 

객체의 대소 관계를 판단하는 comparator를 직접 구현하려면 Comparator 인터페이스를 구현한 클래스를 정의하고 그 클래스형의 인스턴스를 생성해야 한다. 그런 다음 매개변수로 전달된 두 객체의 대소 관계를 비교하여 그 결과를 다음과 같이 반환하는 compare 메서드를 구현하면 된다.

public int compare(T d1, T d2) {
	if(d1 > d2) return 양수;
	if(d1 < d2) return 음수;
	if(d1 == d2) return 0;
}

따라서 클래스 X에 대한 comparator는 아래의 코드와 같이 정의할 수 있다.

import java.util.Comparator;
// 클래스 X의 내부에서 comparator를 정의하는 방법은 다음과 같다.

class X {
	// 필드, 메서드 등
    public static final Comparator<T> COMPARATOR = new Comp();
    
    private static class Comp implements Comparator<T> {
    	public int compare(T d1, T d2) {
        	//d1이 d2보다 크면 양의 값 반환
            //d1이 d2보다 작으면 음의 값 반환
            //d1이 d2와 같으면 0 반환
            }
    }
}

 

Comparator 인터페이스와 compare 메서드를 구현할 클래스를 먼저 작성한다. 그 후에 클래스의 인스턴스를 생선한다. 즉 COMPARATOR로 선언한 인스턴스를 comparator라고 부른다. 지금은 comparator를 클래스 내부에 정의하고 있지만 클래스 외부에서 정의해도 된다.

지금까지의 복잡한 내용에 비해 comparator의 사용법은 간단하다. binarySearch 메서드의 세 번째 매개변수로 클래스 X의 클래스 변수인 X.COMPARATOR를 전달하면 된다. 호출된 binarySearch 메서드는 전달받은 comparator를 기준으로 배열 요소의 대소 관계를 판단하여 이진 검색을 수행한다. 

제네릭 

더보기

제네릭은 처리해야 할 대상의 자료형에 의존하지 않는 클래스(인터페이스) 구현 방식이다. 제네릭 클래스는 자료형에 의존하지 않기 때문에 범용으로 사용할 수 있다. 또한 Java에서 지원하는 기능이므로 안전한 방법으로 사용할 수 있다. 제네릭 클래스는 클래스 이름 바로 뒤에 <Type> 같은 형식의 파라미터를 붙여 선언한다.

class 클래스이름 <파라미터> { /* ... */ }
interface 인터페이스이름 <파라미터> {/* ... */ }

이렇게 정의된 클래스나 인터페이스는 매개변수로 정의한 '자료형'을 전달받을 수 있다. 파라미터 이름을 작성하는 방법은 
다음과 같다.

  1. 1개의 대문자를 사용한다(소문자는 가급적 사용하지 않는다)
  2. 컬렉션(collection)의 자료형은 element의 앞글자인 E를 사용한다
  3. 맵(Map)의 키(key), 값(value)은 key value의 앞글자인 K와 V를 사용한다
  4. 일반적으로는 T를 사용한다

또한 형변수에는 와일드카드를 지정하는 것도 가능하다

<? extends T> : 클래스 T의 서브 클래스를 전달받는다.
<? super T> : 클래스 T의 슈퍼 클래스를 전달받는다.

다음은 제네릭 클래스를 구현한 프로그램이다.

// 제네릭 클래스의 예
public class GenericClassTester {

    static class GenericClass<T> {
        // 제네릭 클래스의 파라미터를 T라고 작성한다.
        private T xyz;
        public GenericClass(T xyz) { // 생성자
            this.xyz = xyz;
        }
        T getXyz() {                // xyz를 반환한다.
            return xyz;
        }
    }

    public static void main(String[] args) {
        // 다음과 같이 파라미터에 String을 넘길 수도 있고 Integer를 넘길 수도 있다.
        GenericClass<String> s = new GenericClass<>("ABC");
        GenericClass<Integer> n = new GenericClass<>(15);

        System.out.println(s.getXyz());
        System.out.println(n.getXyz());
    }
}

 

이상 3장을 마무리한다. 다음 장은 4장. '스택과 큐'에 대해 알아보도록한다.

 

728x90