제1장 소프트웨어공학과 자료구조 개요 이 장에서는 소프트웨어공학과 자료구조에 대해 간단히 소개한다.
1.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 소프트웨어 개발 절차 고품질 소프트웨어의 목표 소프트웨어 설계 방법론 객체지향 설계 방법론 소프트웨어 정확성 검증 방법 자료구조 개요
1.2. 소프트웨어공학
소프트웨어 절차 소프트웨어의 크기가 커지고 복잡해질수록 코딩 외에 다른 소프트웨어 이슈에 대해 관심을 가져야 한다. 팀 프로젝트 수행시 더욱 중요 소프트웨어 개발의 단계 문제 분석: 타당성 조사, 성격, 범위, 목적 요구사항 분석: 사용자 및 시스템 요구사항 소프트웨어 명세서: not how but what 설계 위험 분석 구현 검사와 검증 운영 소프트웨어 생명주기(lifecycle) 유지보수 not sequential!!!
보통 컴퓨터 프로그래밍이라 하면 프로그래밍 언어를 이용하여 코딩을 하는 것만을 생각할 수 있다. 하지만 소프트웨어를 개발한다는 것은 단순히 코딩만 하는 것은 아니며, 여러 가 지 과정을 통해 이루어진다. 특히, 복잡한 소프트웨어일수록 코딩보다는 다른 이슈들이 보 다 더 중요해질 수 있으며, 홀로 개발하는 것이 아니고 팀을 구성하여 개발하는 경우에는
- 1 -
이것이 더욱 뚜렷해진다. 소프트웨어의 개발은 문제분석, 요구사항 분석, 소프트웨어 명세서 (specification) 작성, 설계, 구현, 검사와 검증, 운영, 유지보수 등의 단계로 구성된다. 하지만 이런 단계를 순차적으로 수행하는 것은 아니다. 설계를 하다가 요구사항 분석을 다시 해야 하는 경우도 있을 수 있고, 구현을 하다가 설계를 다시 해야 하는 경우도 있을 수 있다. 또 한 두 단계가 병행으로 수행될 수도 있다.
소프트웨어 절차 – 계속 문제/요구사항 분석할 때 고려 사항 입력의 형태, 데이터의 유효성 판단 기준, 소프트웨어를 사용할 사용자 유형, 사용자 인터페이스의 형태, 예외적인 특수한 경우의 존재 여부, 미래에 추가될 기능 개발할 소프트웨어의 프로토타입(prototype) 또는 일부 구성요소의 프로토타입 프로토타입을 만들면 관련 사람들간에 커뮤니케이션을 향상시킬 수 있으며, 위 문제에 대한 보다 정확한 답을 얻을 수 있다.
소프트웨어 공학 소프트웨어 공학(software engineering): 고품질의 소프트웨어 공학 개발의 모든 측면과 관련된 학문을 말함 사용되는 기술과 행위(문서화, 팀워크 등)를 모두 포함 소프트웨어 프로세스(software process): 시스템을 개발하기 위해 프로세스 개인 또는 조직이 사용하는 소프트웨어 공학 기술의 집합을 말함 사용하는 소프트웨어 기술 또는 도구 하드웨어 소프트웨어: 컴파일러, 디버거 등 아이디어웨어: 소프트웨어 기술과 관련된 지식
소프트웨어공학(software engineering)이란 고품질의 소프트웨어 개발의 모든 측면과 관련된 학문을 말한다. 여기에는 소프트웨어를 개발할 때 사용되는 기술뿐만 아니라 행위도 포함된 다. 소프트웨어 프로세스(software process)란 시스템을 개발하기 위해 개인 또는 조직이 사 용하는 소프트웨어공학 기술의 집합을 말한다. 우리가 소프트웨어를 개발할 때 사용하는 소 프트웨어 기술은 크게 하드웨어, 소프트웨어, 아이디어웨어(ideaware)로 나눌 수 있다. 이 중 이 과목과 가장 밀접하게 연관된 것은 아이디어웨어이다. 아이디어웨어는 소프트웨어를 개발할 때 사용할 수 있는 지식을 말한다. 대표적인 아이디웨어로는 어떤 문제를 해결하는 일련의 유한 절차인 알고리즘(algorithm), 프로그램에서 정보를 처리할 때 이 정보를 모델링
- 2 -
하기
위해
사용하는
자료구조(data
structure),
객체지향개발론과
같은
개발방법론,
UML(Unified Modeling Language)와 같은 소프트웨어를 설계할 때 사용하는 도식화 도구 등 이 있다.
고품질 소프트웨어의 목표 동작해야 한다. 고품질 SW는 그것의 임무를 정확하고 완전하게 수행해야 한다. 완전성, 정확성, 사용의 편리성, 효율성 많은 시간과 노력을 하지 않고 수정할 수 있어야 한다. 소프트웨어의 모든 단계에서 수정이 필요할 수 있음 유지보수: 기능의 추가, 검사 과정에서 발견하지 못한 오류 수정이 용이하기 위한 조건 읽고 이해할 수 있어야 한다. Æ 문서화 다룰 수 있는 정도의 크기로 나뉘어져 있어야 한다. Æ 모듈화 재사용이 가능해야 한다. 요구된 시간 내에 그리고 책정된 비용을 초과하지 않고 완성되어야 한다.
소프트웨어가 올바르게 동작한다는 것은 크게 다음 네 가지 측면에서 접근된다. z 완전성(completeness): 원래 소프트웨어가 제공해야 하는 모든 기능을 제공해야 한다. z 정확성(correctness): 각 기능이 요구된 결과를 주어야 한다. z 사용의 편리성(usability): 인터페이스가 사용하기 편리해야 한다. z 효율성(efficiency): 최소한 요구된 수준만큼의 효율성을 제공해야 한다. 초창기에는 완전성과 효율성을 강조하였지만 컴퓨팅 환경이 발달함에 따라 사용의 편리성에 더 많은 초점을 둔다. 이 관점은 소프트웨어를 사용하는 측의 요구사항이라고 볼 수 있다. 반면에 소프트웨어 개발자 측면에서 이런 특성들을 고려하여 개발하여야 하지만 소프트웨어 개발자는 추가적으로 소프트웨어가 수정 및 확장이 용이하도록 개발해야 한다. 이것은 소프 트웨어 전체 개발 비용에서 초기 개발 비용보다는 유지보수에 더 많은 비용이 소요되기 때 문이다.
- 3 -
문제의 이해 프로그래밍 숙제에 대한 설명으로 분량이 12페이지인 문서가 학생들에게 제시되었다. 기간은 일주일이 주어졌다. 다음은 이것에 대한 학생들의 반응을 조사한 결과이다. Panic and do nothing 39% Panic and drop the course 30% Sit down at the computer and begin typing 27% Stop and think 4%
문제에 대해 정확하게 이해하는 것이 소프트웨어 개발의 가장 첫 단계이다. 생각을 충분하 지 않고 개발에 착수하게 되면 처음에 생각한 아이디어를 버리기 어려워진다. 이것은 인간 의 어떤 본질적인 특성 중 하나이다. 따라서 문제에 대해 충분히 이해하고 분석한 다음에 구현에 착수해야 한다.
문제의 이해 – 계속 자세한 문제의 명세서 작성 요령: 시나리오를 생각해본다. 시나리오: 프로그램을 실행하였을 때 일어날 수 있는 일련의 사건 예1.1) ATM 기계 고객은 은행 카드를 삽입한다. ATM은 카드로부터 계좌번호를 알아낸다. ATM은 PIN 번호를 요청하면 고객은 PIN 번호를 입력한다. ATM은 PIN 번호를 확인한다. ATM은 거래 종류(입금, 이체, 조회, 종료)를 물어본다. 고객은 조회를 선택한다. ATM은 계좌의 현재 잔액을 읽고 그것을 나타내준다. ATM은 거래 종류를 다시 물어본다. 고객은 종료를 선택한다. ATM은 은행카드를 되돌려준다.
문제를 정확하게 이해하기 위해 사용하는 가장 대표적인 방법은 소프트웨어의 동작 시나리 오를 생각해보는 것이다.
- 4 -
소프트웨어 설계 추상화(abstraction): 특정 관찰자 입장에서 시스템의 필수적인 추상화 사항만을 포함하는 복잡한 시스템에 대한 모델 관찰자마다 다를 수 있음 불필요한 하위 수준의 세부사항에 대한 고려 없이 적절한 수준에서 문제에 대해 집중할 수 있도록 해준다. (stepwise-refinement) 추상화된 것은 점진적으로 세분화되어 구체화된다. 하향식(top-down) 상향식(bottom-up) 객체지향 접근 분할정복(divide-and-conquer): 복잡한 문제일 수록 한 덩어리로 분할정복 접근하는 것이 매우 어렵다.
문제에 대한 이해를 바탕으로 소프트웨어를 분석하고 설계를 하게 된다. 소프트웨어를 설계 를 할 때 처음부터 상세한 사항까지 고려하게 되면 문제의 핵심에 대해 집중할 수가 없다. 따라서 가장 먼저 선행되어야 하는 것은 추상화(abstraction)이다. 추상화를 통해 불필요한 하위 수준의 세부사항에 대한 고려 없이 적절한 수준에서 문제에 대해 집중할 수 있다. 추 상화하여 모델을 정립하였으면 그것을 점진적으로 세분화하여 구체화하게 되는데, 이 때 크 게 하향식, 상향식, 객체지향 접근 세 가지 방법이 있다. 이 중 객체지향 접근 방법은 상향 식에 가까운 방법으로 시스템에서 객체들의 후보를 찾아 이들 간에 상호작용을 통해 시스템 이 동작하도록 설계를 하는 방법이다.
소프트웨어 설계 – 계속 전체 문제의 작은 일부분을 모듈(module)이라 한다. 모듈 정보 은닉(information hiding): 모듈은 그것의 복잡한 내부 구조를 은닉 외부로부터 숨긴다. 복잡성을 줄이기 위한 목적 모듈은 그것의 인터페이스를 통해서만 다른 모듈과 상호작용한다. 모듈의 요구사항 느슨한 결합성(loosely-coupled): 모듈은 상호 독립적이어야 결합성 한다. 한 모듈의 변경이나 오류가 다른 모듈에 영향을 주지 않아야 한다. 높은 응집성(highly-cohesive): 잘 정의된 단일 작업만해야 한다. 응집성
하향식 접근 방법에서는 전체 문제를 작은 문제로 나누고, 작은 문제를 다시 그보다 작은 문제로 나누는 과정을 반복하게 된다. 이렇게 문제를 작은 문제로 나누어 해결하고자 하는 방법을 분할정복 방법이라 한다. 분할정복을 통해 얻어지게 되는 전체 문제의 작은 일부분 을 모듈(module)이라 한다. 모듈의 가장 큰 특징은 정보 은닉이다. 즉, 외부로부터 그것의 내부의 자세한 내용 또는 내부 구조를 숨기는 것이다. 모듈은 오직 그것의 인터페이스를 통
- 5 -
해서만 이해되면 된다. 이것은 복잡성을 줄이기 위함이다. 모듈은 결합성이 느슨할수록, 응 집성이 높을수록 바람직하다. 결합성이 느슨하다는 것은 모듈 간에 의존성이 적다는 것을 말한다. 즉, 다른 모듈의 변경에 영향을 받지 않는다는 것을 말한다. 응집성이 높다는 것은 잘 정의된 단일 작업만을 한다는 것을 말한다. 한 모듈에서 여러 작업을 할 경우에는 특정 작업의 오류가 다른 작업에 영향을 줄 확률이 높다.
소프트웨어 설계 – 계속 각 모듈의 설계할 때 중요한 고려사항 모듈 간에 데이터 흐름(data flow) 흐름 소프트웨어 내에서 데이터 흐름을 명확하게 하기 위해서는 각 모듈마다 다음이 고려되어야 한다. (input input) 모듈을 실행하기 전에 제공될 수 있는 데이터는? (assumption assumption) 모듈이 어떤 것을 가정하고 있는가? (output output) 모듈의 행동에 따라 모듈이 실행된 후에 데이터의 모습은? 후에 설명하는 사전/사후조건과 연관되며, 팀 프로젝트에서는 매우 중요한 통신 수단이 된다. 비고. 비고. 이런 흐름은 모듈의 내부의 처리 방법과는 무관하다.
각 모듈을 설계할 때 중요하게 고려되어야 하는 사항 중 하나는 모듈 간에 데이터 흐름이 다. 이를 위해 설계 과정에서 모듈 간에 데이터의 흐름도를 작성하는 경우가 많다. 각 모듈 측면에서 데이터의 흐름은 크게 세 가지 측면에서 고려된다. 첫째, 모듈을 실행하기 전에 모듈에 제공되는 또는 제공될 수 있는 데이터가 무엇인지 결정해야 한다. 둘째, 모듈에서 사용해야 하는 가정을 결정해야 한다. 셋째, 모듈의 행동과 입력 데이터에 따라 모듈이 실 행된 후에 데이터의 모습이 어떻게 되어야 하는지 결정해야 한다. 이런 데이터의 흐름은 후 에 설명하는 메소드의 사전, 사후조건과 밀접하게 연관되어 있다. 데이터의 흐름을 명확하 게 하는 것은 팀 프로젝트에서 모듈을 개발하는 팀원 간에 중요한 통신 수단이 된다. 여기 서 다시 한번 강조해야 하는 것은 데이터의 흐름은 모듈의 내부에서 데이터를 처리하는 방 법과 무관하다는 것이다. 즉, 일련의 데이터를 정렬해야 하는 모듈이 어떤 정렬 알고리즘을 사용하는지는 그것의 입력과 출력과 무관하다는 것이다.
- 6 -
객체지향 설계와 구조화된 설계의 차이점 객체지향 설계(object-oriented design)는 문제에서 객체를 식별하고, 설계 이 객체들을 이용하여 모델링한다. (명사) 데이터 중심 실세계와 가깝게 모델링할 수 있음 구조화된 설계(structured design)는 문제에서 행위를 식별하여 설계 행위 위주로 모델링한다. (동사) 알고리즘 중심
객체지향 프로그래밍의 세가지 핵심 요소 1. 캡슐화(encapsulation): 데이터와 행위의 결합 2. 상속(inheritance): 코드의 재사용 3. 다형성(polymorphism): 실행시간에 객체에 의해 행위가 결정됨
현재 대부분의 범용 프로그래밍 언어는 객체지향 프로그래밍을 지원한다. 객체지향 설계는 기존 구조화된 설계에 비해 실세계를 보다 가깝게 모델링할 수 있다. 객체지향 설계의 핵심 은 주어진 문제에서 객체를 식별하는 것이다. 이런 객체들은 문제를 설명하는 문장에서 명 사에 해당된다. 반면에 구조화된 설계는 문제에서 행위를 식별하는 것이 핵심이다. 즉, 문제 를 설명하는 문장에서 행위는 동사에 해당된다. 이런 측면에서 객체지향 설계는 데이터 중 심이라 하고, 구조화된 설계는 알고리즘 중심이라 한다.
객체지향 설계 클래스 발견 문제 정의에서 명사와 동사를 찾는다. 명사는 객체로 동사는 객체의 행위로 보통 모델링된다. 보통 주요 클래스를 발견하는 것은 어렵지 않다. 가능하면 클래스를 상속 계층구조로 조직화한다. 각 클래스가 오직 하나의 주요 책임만을 가지도록 한다. 추상화를 사용하여 우선 자세한 세부사항은 고려하지 않는다. 여러 사람들과 브레인스토밍(brainstorming)을 한다. 여러 시나리오를 생각해본다.
이미 언급한 바와 같이 객체지향 설계의 핵심은 객체를 식별하는 것이다. 클래스는 같은 종 류의 객체를 설명하는 틀이다. 따라서 문제에서 객체를 식별한다는 것은 문제에서 클래스를 발견한다는 것과 같은 의미이다. 클래스는 문제를 구성하는 모듈로 볼 수 있으므로 앞서 설 명한 모듈이 갖추어야 하는 조건을 클래스도 갖추어야 한다. 즉, 한 클래스는 다른 클래스 와 독립적이어야 하며, 한 클래스는 오직 하나의 주요 책임만 가져야 한다.
- 7 -
객체지향 설계 예) 문제의 정의: 주소록 책 작성 잠재적 객체 겉장 항 페이지 이름 집전화번호 전화번호 회사전화번호 팩스번호 휴대전화번호
생년월일 주소 집주소 회사주소 사용자 요구사항 회사이름 - 필요없다고 판단 달력 소유자 정보 사용자 Æ 사용자 인터페이스
어떤 주어진 문제에서 객체를 식별하는 과정을 예를 통해 설명하고자 한다. 주어진 문제는 주소록 책 작성이다. 우선 실세계에서 사용하는 주소록을 관찰하고 경험을 바탕으로 생각나 는 잠재적 객체들을 모두 나열한다. 그 다음 나열된 것들에서 소프트웨어로 구현하였을 때 필요하지 않는 것, 통합되어야 하는 것, 상속 개념을 도입할 수 있는 것이 없는지 살펴본다. 예를 들어, 실세계의 주소록은 여러 페이지로 구성되어 있는데 소프트웨어 측면에서는 페이 지라는 개념은 필요 없고, 페이지를 구성하는 각 사람의 주소 정보를 유지하는 항(entry)이 필요하다. 또 집전화번호, 회사전화번호, 팩스번호 등은 보통 모두 다른 객체로 표현하지 않 을 것이다. 이들은 전화번호라는 하나의 클래스로 보통 나타낼 것이다. 이런 과정을 거친 다음에는 각 객체마다 CRC(Class, Responsibility, Collaboration) 카드를 작성하여, 각 객체 의 책임과 다른 객체와 어떤 상호작용을 하는지 분석한다.
객체지향 설계 예) 계속 각 객체마다 CRC(Class, Responsibility, Collaboration) 카드를 작성 클래스 이름: entry
부모 클래스:
자식 클래스:
주요 책임: 주소록 한 항에 대한 정보를 관리한다. 책임
협동
전체 이름을 하나의 문자열로 제공
name 클래스로부터 성을 name 클래스로부터 이름을
주소 제공
없음
- 8 -
객체지향 설계 예) 계속 시나리오 검토 사용자가 주소록에서 주소를 찾고 싶다. 사용자 인터페이스는 사용자로부터 이름을 입력받아야 한다. 어디서 찾나? Æ 주소록 전체를 나타내는 객체가 필요함 비교를 해야 한다. 어디서? Æ 주소록 or 항 항에서 이름을 받아 사용자가 입력한 이름과 비교한다. 일치하는 것을 찾으면 그 항으로부터 주소를 전달받는다. 다른 시나리오 검토 새 항의 추가, 기존 항의 삭제 등
모든 객체에 대해 CRC 카드를 작성하였으면 발생 가능한 여러 시나리오를 검토하여 작성 된 내용에 빠진 것은 없는지, 잘못된 것은 없는지 검토한다. 시나리오를 검토하는 도중에 미처 생각하지 못했던 새 클래스를 발견할 수 있다. 설계가 끝나면 구현을 시작할 수 있다. 소프트웨어 구현이 완료되거나 아니면 개발하는 도 중에 소프트웨어의 정확성을 확인하기 위한 여러 가지 시험을 할 수 있다.
소프트웨어의 정확성 검증 테스팅(testing): 오류를 발견하기 위해 설계된 데이터 집합을 이용하여 프로그램을 실행하는 과정 디버깅(debugging): 발견된 오류를 제거하는 과정 승인 시험(acceptance test): 실제 데이터를 이용하여 실제 환경에서 시스템을 테스트하는 과정 오류는 소프트웨어 개발 단계에서 일찍 발견할수록 수정 비용이 저렴하다. 명세서 또는 설계 오류가 가장 치명적이다.
테스팅은 오류를 발견하기 위해 설계된 데이터 집합을 이용하여 프로그램을 실행하는 과정 을 말하며, 디버깅은 테스팅을 통해 발견된 오류를 제거하는 과정을 말한다. 상용 소프트웨 어의 경우에는 출시하기 전에 승인 시험을 거치게 되는데 이것은 실제 소프트웨어가 운용될 환경에서 실제 데이터를 이용하여 테스트하는 과정을 말한다. 오류는 개발 단계에서 일찍 발견할수록 수정 비용이 저렴하다. 이런 측면에서 명세서 또는 설계 오류가 가장 치명적이 다.
- 9 -
구현 오류 컴파일 시간 오류(compile-time error): 문법 오류 실행 시간 오류(run-time error) 잘 못된 가정: result = dividend / divisor 사용자 입력 오류 논리 오류 오류가 발생하였을 때 그것을 극복할 수 있는 능력을 강건성 (robustness)이라 한다. 예외 처리(exception handling) 설계부터 어떤 가능한 예외 상황이 발생할 수 있는지 고려해야 함 what, where, how
구현 오류는 크게 컴파일 시간 오류와 실행 시간 오류로 나눌 수 있다. 컴파일 시간 오류는 다른 말로 문법 오류라 하며, 이것은 보통 쉽게 정정할 수 있다. 문법적인 오류가 없지만 실행하였을 때 원하는 결과를 얻지 못하거나 프로그램이 실행 도중에 의도하지 않게 중단되 는 경우 등을 실행 시간 오류라 한다. 특히 이 중에 논리적으로 작성을 잘못하여 발생된 오 류는 그것을 극복하기가 어렵다.
정확성을 위한 설계 사전조건(precondition): 사후조건이 보장되기 위해 메소드에 사전조건 진입하기 전에 반드시 만족해야 하는 가정 메소드를 호출하는 사용자의 책임 사전조건이 위배된 경우에는 어떻게? 방법1. 방법1. 예외 처리 방법2. 방법2. 아무것도 하지 않는다. 사후조건(postcondition): 사전조건이 충족되었을 때, 사후조건 기대되는 메소드의 실행 결과 어떻게 그 결과를 얻는지는 중요하지 않음 메소드를 구현하는 개발자의 책임 사전, 사후조건에 대한 올바른 이해가 없으면 여러 가지 논리 오류를 범할 수 있다.
오류는 항상 발생할 수 있다. 오류가 발생하였을 때 그 오류를 찾아 수정하는 것도 중요하 지만 처음부터 오류가 발생하지 않도록 구현하는 것도 중요하다. 정확한 소프트웨어를 설계 하기 위해 사용되는 가장 기본적인 기법은 각 함수 또는 메소드의 사전조건과 사후조건을 명백하게 제시하는 것이다. 원칙적으로 사전조건은 그것을 호출하는 측의 책임이다. 이 조 건이 충족되지 않으면 메소드는 사후조건을 보장해줄 책임이 없다. 그러나 보통 메소드를 구현하는 측에서도 사전조건을 검사하여 그것이 충족되지 않으면 예외 처리를 하거나 아무 것도 하지 않는다.
- 10 -
정확성을 위한 설계 – 계속 사후조건의 종류 종류 1. 반환 값의 정확성 종류 2. 객체의 상태 사전, 사후조건의 예) void RemoveLast() 효과: 리스트에 있는 마지막 요소를 제거한다. 사전조건: 리스트가 비어있지 않아야 한다. 사후조건: 리스트에 있는 마지막 요소가 제거되어 있다. WARNING If you try to execute this operation when the preconditions are not true, the results are not guaranteed
Deskchecking – Design 각 클래스의 기능과 목적이 명확한가? 큰 클래스를 세분화할 수 없는가? 공통된 코드를 공유하는 클래스들이 있는가? 있으면 이들을 상속 계층구조를 이용하여 나타낼 수 있는가? 모든 가정이 합당하며, 문서에 잘 나타나 있는가? 모든 사전/사후조건이 정당한가? 명세서와 비추어 봤을 때 설계가 완전하고 정확한가?
보통 테스팅이나 디버깅은 디버거와 같은 시스템 소프트웨어를 이용하여 수행하게 되며, 일 반적으로 프로그램을 실행하면서 검사를 한다. 반면에 desk checking이란 설계서나 프로그 램 코드를 출력하여 책상 위에 사용자가 직접 검토하는 행위를 말한다. 이런 검토는 설계에 관한 것일 수 있고 구현에 관한 것일 수 있다.
- 11 -
Deskchecking – Coding 프로그래밍 언어의 기능을 올바르게 사용하고 있는가? 설계에 나타난 인터페이스와 일관성 있게 메소드들이 구현되었는가? 메소드 호출의 실제 인수와 메소드 정의에 선언된 파라미터가 일치하는가? 각 데이터 값들의 초기화가 제대로 되어 있는가? 모든 루프가 종료하는가? 매직 값들은 없는가? (매직 값은 값 자체를 보면 그 의미를 알기 어려운 값 Æ 명명된 상수 사용) 각 상수, 클래스, 변수, 메소드의 이름이 적절한가?
테스팅 – 단위 테스팅 블랙 박스 테스팅 (데이터 위주 테스팅) 기능 영역: 유효한 입력의 집합 기능 영역이 작으면 모든 경 우를 검사할 수 있다. 랜덤 검사: 기능 영역에서 몇 개의 입력을 랜덤하게 선택하여 검사하는 방법 기능 영역을 분류하여 각 소 영역 별 하나의 입력을 검사 하는 방법. 예) 입력이 정수: 음수, 0, 양수
투명 박스 테스팅 (코드 위주 테스팅) 메소드의 각 문장을 차례로 실행하면서 검사하는 방법 분기(branch): 항상 실행 되지 않는 일련의 문장 경로(path): 메소드가 실행 되었을 때 실행될 수 있는 분기의 조합 경로 검사(path testing): 모든 경로를 다 실행하면서 검사하는 방법
테스팅이란 오류를 발견하기 위해 미리 설계된 데이터 집합을 이용하여 프로그램을 실행하 는 것을 말한다. 이런 테스팅은 크게 블랙박스 테스팅, 투명 박스 테스팅, 두 가지 종류로 구분된다. 블랙 박스 테스팅은 구현의 내부 내용에 대한 고려없이 어떤 입력에 대해 올바른 출력을 주는지 검사하는 방법이다. 반대로 투명 박스 테스팅은 구현의 내부 내용을 고려하 여 입력을 정하여 검사하는 방법이다.
- 12 -
루프의 정확성 검증 루프 불변조건(loop invariant): 루트가 시작되기 전, 루프가 반복된 불변조건 후, 루프가 종료된 후에 항상 만족되어야 하는 조건을 말한다. 추가적으로 알고리즘의 정확성을 충족시켜야 한다. 예1.2) 배열 item에 있는 요소들의 합 구하기 int sum = 0; int i = 0; while(i<n){ sum += item[i]; i++; }
루프 불변조건. sum은 item[0]부터 item[i-1]까지의 합이어야 한다.
루프의 정확성을 검증하기 위해 많이 사용하는 방법은 루프 불변조건(loop invariant)을 확인 하는 것이다. 루프 불변조건이란 루프가 시작되기 전, 루프가 반복된 후, 루프가 종료된 후 에 항상 만족되어야 하는 조건으로서, 알고리즘의 정확성과 일치되는 조건이어야 한다. 즉, 루프가 종료되면 불변조건이 유지되어야 하며, 이 때 결과가 정확해야 한다.
1.3. 자료구조 개요 자료구조(data structure)란 데이터를 컴퓨터에 표현, 저장, 관리하는 방법을 말한다.
데이터, 데이터 타입 데이터: 데이터 프로그램에 의해 처리 되는 정보 보통 데이터라 하면 값(value)를 의미하는 경우가 많음 데이터 타입: 타입 다음 두 가지에 의해 정의된다. 이 데이터 타입이 표현할 수 있는 요소들의 집합. 예1.3) 정수 이 요소들에 적용할 수 있는 연산의 집합. 예1.4) 사칙연산 프로그래밍 언어는 두 종류의 데이터 타입을 제공한다. 시스템 정의 데이터 타입 단순 타입(원자 타입, 원시 타입): 더 이상 분해할 수 없는 타입 타입 복합 타입: 타입 여러 요소로 구성되어 있는 타입 Æ 요소 접근 연산 구조화된 타입과 비구조화된 타입 사용자 정의 데이터 타입
데이터란 프로그램에 의해 처리되는 정보를 말한다. 데이터 타입은 같은 종류의 데이터를 식별하기 위해 사용하는 용어로서, 이 타입으로 표현할 수 있는 요소들의 집합과 이 요소들 에 적용할 수 있는 연산의 집합에 의해 정의된다. 보통 프로그래밍 언어들은 시스템 정의 타입과 사용자 정의 타입 두 가지 종류의 데이터 타입을 제공한다. 시스템 정의 타입은 다 시 크게 단순 타입과 복합 타입으로 나누어지며, 단순 타입은 더 이상 데이터를 분해할 수
- 13 -
없는 타입이고, 복합타입은 배열처럼 여러 요소로 구성되어 있는 타입이다. 복합 타입에서 는 각 요소를 접근하는 연산이 중요하다.
추상화 절차의 추상화(procedural abstraction) 추상화 메소드의 목적과 그것의 구현을 분리 데이터 추상화(data abstraction, encapsulation) 추상화 데이터에 가능한 연산과 데이터 저장 방법 및 연산 구현을 분리 추상 데이터 타입(ADT, Abstract Data Type) 타입 특정 구현과 무관하게 특성이 명시된 데이터 타입 프로그래밍 언어에서 제공하는 int 타입 역시 ADT로 볼 수 있다. 그것의 내부 구현을 몰라도 사용할 수 있다.
데이터를 처리함에 있어서도 추상화는 매우 중요하다. 대부분의 프로그래밍 언어에서 제공 되고 있는 정수형 타입인 int의 경우에도 우리는 그것이 내부적으로 어떻게 저장되어 있고, 그것과 관련된 연산들이 어떻게 구현되어 있는지 자세히 모르는 상태에서 사용하고 있다. 이 과목에서 배우는 자료구조의 가장 큰 핵심은 각 종 데이터를 컴퓨터에 표현, 저장, 관리 하기 위한 새로운 데이터 타입을 구현하는 것이며, 이 때 개발된 데이터 타입은 그것의 자 세한 내부 구현을 알지 못하여도 사용할 수 있어야 한다. 이와 같은 특성을 추상 데이터 타 입(ADT, Abstract Data Type)이라 한다.
자료구조 자료구조(data structure) 자료구조 정의1. 정의1. 데이터를 저장하는 방법과 관련된 것으로서 데이터 요소들 의 모음을 모음 말한다. 이 모음의 논리적 구성은 개별 요소 간에 논리적 관계를 관계 나타낸다. 정의2. 정의2. 데이터의 모음을 저장하기 위해 프로그래밍 언어를 이용하여 정의할 수 있는 구조 자료구조는 개별 데이터 요소를 검색하고 저장하기 위해 사용되는 접근 연산에 의해 특징지어진다. 특징 자료구조는 ADT로 구현된다.
정수와 같은 단순 타입은 카운터나 배열의 색인과 같은 정보를 나타내기 위해서는 유용하게 사용될 수 있다. 하지만 실제 소프트웨어에서 우리가 다루는 많은 데이터는 정수와 같은 단 순 타입으로 표현할 수 없는 것이 더 많다. 보통은 사용자가 새롭게 정의한 복합 타입을 많 이 이용한다. 예를 들어 성적 처리 프로그램에서는 학생에 관한 여러 정보를 유지하기 위해
- 14 -
복합 타입을 정의하여 사용할 것이다. 그런데 이 때 한 학생의 정보만을 유지하는 경우는 드물며, 여러 학생들의 정보를 모아 유지하는 것이 보통이다. 이처럼 데이터 요소들의 모음 을 자료구조라 하며, 이 때 각 요소는 복합 타입일 수도 있고, 단순 타입일 수도 있다. 이런 자료구조는 개별 요소를 접근하는 연산에 따라 그 특징이 정의된다. 특히, 자료를 어떻게 조직하느냐에 따라 각 접근 연산의 성능에 큰 영향을 준다.
자료구조 – 계속 자료구조의 특성 구성 요소로 분해될 수 있다. 요소들이 조직되어 있는 형태는 각 개별 요소에 대한 접근 방법에 영향을 준다. 조직되어 있는 형태와 접근 방법을 모두 캡슐화할 수 있다. 자료구조에서 제공하는 기본 연산의 분류 생성자: 생성자 새 인스턴스를 생성할 때 사용되는 연산 기존 객체의 내용을 이용하여 새 객체를 생성하는 생성자를 복사 생성자(copy constructor)라 한다. 수정자: 수정자 데이터의 값들의 상태를 변경할 때 사용되는 연산 관찰자: 관찰자 데이터의 값들의 상태를 열람할 때 사용되는 연산 반복자(iterator): 데이터 구조에 있는 모든 구성요소를 순차적으로 반복자 처리할 수 있도록 해주는 연산
알고리즘과 데이터 알고리즘(algorithm): 유한 단계 내에 문제를 해결하는 방법을 알고리즘 단계별로 기술한 것을 말한다. 알고리즘과 데이터는 서로 매우 밀접하게 관련되어 있음 자료구조에 따라 그것에 적용할 수 있는 알고리즘이 달라진다. 데이터를 정렬된 상태로 유지하고 임의 접근을 제공하는 구조는 이진 검색이 가능하다.
알고리즘은 유한 단계 내에 문제를 해결하는 방법을 단계별로 기술한 것을 말한다. 어떤 문 제가 주어졌을 때 그것을 해결하는 알고리즘은 무수히 많을 수 있다. 소프트웨어 개발자는 어떤 문제를 해결하는 여러 알고리즘이 주어졌을 때 주어진 조건에서 가장 적합한 알고리즘 을 선택할 수 있어야 한다. 알고리즘은 그것이 처리하는 데이터에 따라 제한될 수 있다. 예 를 들어 데이터를 정렬된 상태로 유지하고 임의 접근을 제공하는 자료구조를 사용할 경우에 는 선형 검색이 아닌 이진 검색을 할 수 있다.
- 15 -
제2장 자바 복습 이 장에서는 자바에 대해 복습한다.
2.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 자바의 특성 이해와 복습
2.2. 자바의 역사
자바 역사 1991년: Sun Microsystems 사의 James Gosling과 Patrick Naughton이 개발한 고급 프로그래밍 언어 프로젝트 코드명: Green 원래는 지능형 TV와 같은 가정용 가전 제품에 사용될 언어로 개발됨 각 제조업체는 다른 CPU를 사용할 수 있으므로 특정 컴퓨터 구조에 독립성을 가지는 것이 가장 중요한 설계 목표였다. 기계어와 독립적인 중간 코드(intermediate code)를 생성하는 코드 이동 가능 언어 개발 (write-once run anywhere) C++ 기반: 객체지향 언어 업계 반응: 냉대
1991년 Sun 사의 James Gosling과 Patrick Naughton은 케이블 TV 스위치 박스와 같은 가 전제품에 사용될 수 있는 작은 프로그래밍 언어를 개발하고자 하였다. 가전제품의 각 제조 업체는 다른 CPU를 사용할 수 있기 때문에 이식성(portability)이 이들에게 가장 중요한 설 계 목표가 되었다. 그 결과 기존 프로그래밍 언어처럼 기계에 의존적인 실행 파일을 만들어
- 17 -
실행하는 방식이 아닌 기계와 독립적인 중간코드(intermediate code)를 사용하는 방식을 채 택하였다.
이
중간코드를
바이트
코드(bytecode)라
하며,
이
코드는
가상기계(virtual
machine)를 통해 번역(interpreter) 방식으로 실행된다. Sun 사는 UNIX 기반 워크스테이션이 주력 제품이므로 이들은 C++ 언어를 기반으로 하여 언어를 개발하였다. 이렇게 탄생된 언 어가 자바이지만 처음에는 큰 반응을 얻지 못하였다.
자바 역사 – 계속 WWW의 급성장 Gosling 등은 이 언어를 이용하여 1995년도에 HotJava라고 하는 웹 브라우저를 개발함 이 브라우저는 기존 브라우저와 달리 자바로 작성된 애플릿 (applet)이란 프로그램을 웹에서 다운받아 실행할 수 있음 안전성과 이식성이 중요한 이슈 현재는 마이크로소프트 인터넷 익스플로러에서도 지원 1996년: Sun Microsystems 사는 자바의 첫 버전 출시 실제 응용을 개발하기에는 기능이 미흡 1998년: Java 2 출시 현재는 J2SE(Core/Desktop), J2EE(Enterprise), J2ME(Mobile) 세 종류의 버전을 제공
자바가 개발된 시기는 우연히 인터넷 특히 월드와이드웹(WWW, World Wide Web)이 폭발 적으로 급성장한 시기와 일치하였다. Gosling은 자신이 개발한 자바 언어를 이용하여 그 당 시까지는 상상을 하지 못하였던 새로운 개념의 웹 브라우저를 개발하였다. 이 브라우저는 자바로 작성된 애플릿(applet)이라고 하는 프로그램을 웹에서 다운받아 웹 페이지 내에서 실 행할 수 있었다. 이 개념이 큰 호응을 얻었으며, 그 결과 자바 언어도 전세계적인 관심을 받게 되었다. 이후 Sun사는 1996년에 자바의 첫 버전을 공시적으로 출시하였다. 하지만 첫 버전은 높은 수준의 응용 프로그램을 개발하기에는 매우 부족하였다. 1998년 Java 2가 출 시되면서 본격적으로 다른 범용 프로그래밍 언어와 어깨를 나란히 하게 되었다.
- 18 -
2.3. 자바의 특성
자바의 장점 단순: C++에서 모호하거나 자주 사용되지 않는 기능 제거 완전한 객체지향 언어: C++보다 더 안전성 향상(bug-free) 타입의 강제성 수동 메모리 할당과 해제 제거 Æ garbage collection 포인터 연산 제거 할당 연산과 비교 연산 혼동 제거 다중 상속 제거, 대신 인터페이스 개념 도입 플랫폼 독립성 이식성이 높음 해석 기반(cf. 컴파일) 다중 쓰레드 지원 분산 지원: TCP/IP 통신 지원
자바는 많은 장점을 지닌 프로그래밍 언어이다. 첫째, 자바는 C++를 기반하여 만들어진 언 어이지만 C++에 존재하는 모호한 기능 또는 자주 사용되지 않는 기능을 제거하여 보다 쉽 게 배우고 프로그램을 작성할 수 있도록 하였다. 둘째, 자바는 완전한 객체지향 언어이다.
C++의 경우에는 기존 C처럼 전혀 객체지향 개념을 사용하지 않는 프로그램을 작성할 수 있지만 자바는 그렇지 않다. 셋째, 매우 강건한 언어이다. 즉, 오류가 없는 신뢰성이 높은 프로그램을 개발할 수 있다. 특히, 자바에는 포인터 연산이 없으며, 동적으로 할당받은 메모 리 공간을 프로그래머가 직접 반납할 필요가 없다. 넷째, 이식성이 매우 높은 언어이다. 자 바 가상 기계가 있으면 기계/컴퓨터의 종류와 상관없이 코드를 변경하지 않고 실행할 수 있 다. 이 외에도 다중 쓰레드 지원 기능, 분산 지원 기능 등 많은 장점을 지니고 있다.
자바에 대한 오해 자바는 HTML의 확장이다. 자바는 배우기 쉬운 프로그래밍 언어이다. 자바는 프로그램하기 쉬운 환경을 제공한다. 자바는 모든 플랫폼을 위한 범용 프로그래밍 언어가 될 것이다. 자바는 또 다른 프로그래밍 언어이다. 모든 자바 프로그램들은 웹 페이지 내에서 수행된다. 자바 applet은 보안적으로 매우 위험하다. 자바스크립트는 자바의 단순 버전이다.
보통 자바에 대해 몇 가지 오해를 하기가 쉽다. 자바는 웹에 있는 애플릿을 통해 가장 많이 접하게 된다. 따라서 자바가 HTML의 확장으로 오해하는 경우도 있다. 하지만 자바는 범용 프로그래밍 언어이며, 웹과 전혀 관련이 없는 프로그램도 개발할 수 있다. 자바는 배우기
- 19 -
쉬운 프로그래밍 언어로 이해하는 경우도 많다. 자바가 다른 언어들에 비해 배우기가 쉬운 측면도 있지만 자바처럼 강력한 프로그래밍 언어들을 능숙하게 사용하기 위해서는 많은 노 력이 필요하다. 자바는 프로그래밍하기 쉬운 환경을 제공한다고 생각할 수 있다. 이것은 최 근에 어느 정도 현실화되고 있다. 하지만 초창기에는 마이크로소프트 Visual 언어들이 제공 하는 통합 개발 환경과 같은 개발 환경이 없었다. 자바스크립트는 자바의 단순 버전으로 생 각하는 경우도 있다. 이름만 유사할 뿐 자바스크립트와 자바는 다른 것이다. 자바스크립트 는 Netscape 사에서 개발한 스크립트 언어이며, 자바와 유사한 문법적 구조를 지니고 있어 그들이 이름을 자바스크립트로 명명한 것일 뿐 자바와는 전혀 다른 것이다.
2.4. 객체지향 프로그래밍
OOP의 기본 개념 프로그램은 객체(object)로 구성됨 객체 객체는 행위(behavior), 상태(state), 식별자(identity)를 가짐 캡슐화(encapsulation): 데이터와 행위를 하나로 결합 객체는 메시지를 받아 그것을 처리해준다. 내부 구현에 대해서는 don’t care 한 객체는 다른 객체의 내부 데이터를 절대 직접 조작하지 않음 클래스(class) 클래스 같은 종류의 객체들의 모임 클래스는 이름을 가지며, 멤버변수(member/instance variable, field, attribute)와 메소드(method, member function)로 구성되어 있다. 클래스 간에 관계: use, has-a(aggregation), is-a(generalization) 인스턴스(instance): 클래스의 한 객체 인스턴스
자바 언어를 제대로 활용하기 위해서는 객체지향 프로그래밍에 대한 기본 개념을 정확하게 이해하고 있어야 한다. 객체지향 프로그래밍에서 프로그램은 객체로 구성되며, 객체들 간에 상호작용을 통해 원하는 목적을 달성한다. 객체는 행위, 상태, 식별자를 지닌다. 보다 중요 한 것은 이전 프로그래밍 방식에서는 데이터와 그것을 처리하는 함수가 분리되어 존재하였 지만 객체지향에서는 데이터와 그것을 처리하는 연산이 결합되어 객체라는 형태로 존재한 다. 이렇게 데이터와 행위를 하나로 결합하는 것을 캡슐화라 한다. 객체는 그것이 제공하는 인터페이스를 통해 외부와 상호작용한다. 외부에서는 객체의 내부 구현에 대해서는 알 필요 가 없다. 클래스는 같은 종류의 객체들의 모임이다. 프로그래밍 언어 관점에서 보면 클래스는 객체의 모습을 정의하고 있는 틀이다. 클래스는 멤버변수와 메소드로 구성된다. 클래스 간에 관계 는 크게 사용(use), 포함(has-a), 상속(is-a) 세 가지 종류로 분류할 수 있다. 사용 관계는 한 클래스의 객체가 그에게 주어진 일을 달성하기 위해 다른 클래스의 도움이 필요한 경우를 말한다. 포함 관계는 한 객체가 멤버 변수로 다른 객체를 가지고 있는 경우를 말한다. 상속 관계는 객체지향에서 가장 중요한 개념 중 하나이다. 이것에 대해서는 다음 슬라이드에서 보다 자세히 설명한다. 클래스의 한 객체를 인스턴스(instance)라 한다.
- 20 -
OOP의 기본 개념 상속(inheritance): is-a 관계 상속 super/sub, base/derived, parent/child 세분화(specialization), 일반화(generalization) 재사용 용이하게 해준다. 관련 키워드: extends, super 자바는 다중 상속을 지원하지 않는다. 상속 계층구조, 상속 체인 다형성(polymorphism) 연산은 객체에 따라 다른 행위를 한다. (late binding) overloading cf. overriding
객체지향 프로그래밍 언어에서 상속은 코드의 재사용을 높여 주는 중요한 특성이다. 예를 들어 학교 관리 시스템을 만들고자 한다. 학교 관리 시스템에서는 학교에서 근무하는 선생 님, 직원에 관한 정보를 유지해야 할 뿐만 아니라 학생들에 관한 정보도 유지해야 한다. 이 들은 모두 사람이라는 공통된 특성을 가지고 있다. 즉, 사람 이름처럼 공통적으로 가지고 있는 특성이 있다. 이런 특성과 이런 특성을 조작하는 연산을 각각 중복하여 정의하기 보다 는 사람이라는 클래스를 만들어 여기에 정의하고, 선생님, 직원, 학생은 모두 사람 클래스로 부터 상속된 자식 클래스를 이용하여 정의하면 많은 코드를 재사용할 수 있다. 다형성(polymorphism)은 객체지향 프로그래밍의 또 다른 핵심 키워드이다. 우리는 “문을 열 다”, “창문을 열다”처럼 같은 “열다”라는 표현을 여러 종류의 사물에 사용한다. 하지만 “열다” 의 내부 메커니즘은 그것의 대상이 되는 문, 창문, 서랍에 따라 다르다. 객체지향 프로그래 밍에서도 같은 이름의 메소드를 여러 클래스에 반복하여 사용할 수 있다. 특히, 다형성은 상속과 결합하여 사용할 때 더욱 그 진가를 발휘한다. 객체지향과 관련하여 또 많이 등장하는 용어는 오버로딩(overloading)이다. 이것은 한 클래 스 내에 같은 이름의 메소드를 여러 개 정의하는 것을 말한다. 이 때 제한은 메소드의 서명 이 달라야 한다. 즉, 메소드의 파라미터 목록이 달라야 한다. 오버로딩과 혼동할 수 있는 용 어로 “overriding”이라는 용어가 있다. 이 용어는 상속과 관련된 용어로서 자식 클래스에서 부모 클래스에 있는 메소드를 재정의하는 것을 말한다.
- 21 -
클래스 설계 요령 멤버변수는 항상 private 멤버변수로 멤버변수를 항상 초기화한다. 클래스 내에 기본 타입을 많이 사용하지 마라. 예) private String street; private Address addr;
private String city; private String state;
모든 멤버변수가 열람자와 수정자 메소드를 필요로 하지 않는다. 클래스를 정의할 때 표준 형태를 사용하라. (클래스 내부 구성) 클래스 이름: 대문자로 시작 멤버변수와 메소드: camel case 한 클래스에게 너무 많은 책임을 주는 것은 피하라. 클래스와 메소드의 이름을 적절하게 부여하라.
자바 프로그램에는 클래스 밖에 없으며, 클래스를 어떻게 설계하느냐에 따라 프로그램의 성 능, 가독성 등이 결정된다. 클래스를 설계하는 기본 요령은 멤버변수는 항상 private 멤버로 선언한다. 상속 때문에 멤버변수를 protected 멤버로 선언하는 경우도 있지만 public 멤버로 사용하는 경우는 거의 없다. 또한 너무 많은 멤버변수를 사용하는 것은 바람직하지 않다. 클래스의 멤버변수는 반드시 올바르게 초기화해야 한다. 일반적으로 프로그래머들은 멤버변 수마다 그것에 대한 열람자와 수정자 메소드를 항상 정의하는 경우가 많다. 하지만 모든 멤 버 변수가 반드시 열람자, 수정자 메소드가 필요한 것은 아니다. 한 클래스에 너무 많은 책 임을 주는 것은 피해야 한다. 한 클래스가 너무 많은 일을 하고 있으면 이 클래스를 여러 개의 작은 클래스로 분리가 가능한지 살펴보아야 한다. 클래스를 작성할 때에도 표준 형태 를 사용해야 한다. 실제로 정해진 어떤 표준 형태는 없지만 전체적으로 통일성이 있어야 하 며, 각종 이름은 의미가 잘 전달되도록 명명해야 한다.
2.5. 자바에 대한 이해를 알아보기 위한 질문 목록
질문 자바는 철자 구분을 하는가? 한 파일에 여러 클래스를 정의할 수 있다. (참 또는 거짓) 자바에서 제공하는 기본 타입(원시 타입)은? 자바에서 제공하는 타입을 크게 두 가지로 분류하면? C에서 int 타입과 자바의 int 타입의 차이점은? C에서 char 타입과 자바의 char 타입의 차이점은? 지역변수는 반드시 초기화해야 한다. (참 또는 거짓) 자바에는 열거형 타입이 있다. (참 또는 거짓) 변수 선언에서 final의 final 용도는? for(int i=0; i<10; i++) {}에서 i의 scope는? int n = 2.5; 문장은 오류인가?
- 22 -
질문 &&, || 연산자와 &, | 연산자의 차이점은? short-circuit evaluation이란? dangling-else 문제란? switch 문에서 “fall through” 행위란? new 연산자의 용도? 문자열 생성의 특이한 점은? int[ ] nlist = new int[10]; int[ ] alist = nlist; alist[3]=2; 그러면 nlist[3]? call-by-value와 call-by-reference의 차이점? garbage란, garbage collection이란?
]
질문 멤버변수는 명백하게 초기화하지 않으면 자동으로 초기화 된다. (참 또는 거짓) 초기화하는 방법의 종류? 각 기본 타입의 초기값은? public, private, protected, default access specifier란? 생성자(constructor) 메소드란? 생성자의 종류는? 메소드 서명(method signature)이란? 숨겨진 인수(implicit argument)란? 모든 메소드는 숨겨진 인수를 가진다. (참 또는 거짓) 수정자(mutator) 메소드와 열람자(accessor) 메소드란? 다른 말로 transformer, observer 메소드
- 23 -
질문 public 멤버변수는 열람자, 수정자 메소드 없이 사용할 수 있다. 하지만 public 멤버변수는 거의 사용하지 않는다. 그 이유는? super 키워드? super나 this를 이용한 생성자 호출의 제한은? static 멤버변수와 메소드의 의미? cf. 클래스 변수 상수를 선언할 때 보통 다음과 같이 선언한다. public static final int MAX = 100; 여기서 public, static, final을 사용한 이유는? 클래스 B가 클래스 A를 상속하였다. a는 A의 인스턴스이고, b는 B의 인스턴스이다. a=b와 b=a 중 어느 것이 유효한 문장인가?
질문 package란? 어떤 특정 패키지를 사용하는 두 가지 방법? 자바 클래스 라이브러리 중 import 없이 사용할 수 있는 패키지는? import java.awt.*;을 사용하면 java.awt 패키지에 있는 모든 클래스가 프로그램이 실행될 때 포함된다. (참 또는 거짓)? instanceof 연산자의 기능은? cf. getClass() 메소드 abstract 메소드란, abstract 클래스란? 불변 객체(immutable object)란? Object 클래스란? Wrapper 클래스란? 자바는 다중 상속을 제공하나? interface란?, 그것의 용도는? 내부(inner) 클래스란?
- 24 -
제3장 배열 이 장에서는 자바에서 제공하는 복합 타입 중 하나인 배열에 대해 살펴본다.
3.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 자바 배열 ArrayList Vector
3.2. 배열
배열
z z z
용량: 10 크기: 3
배열의 특성 동질 구조(homogeneous structure): 구조에 있는 모든 요소는 구조 같은 타입이다. 예2.1) 일차원 배열에서 첫 슬롯에 배열에 저장되어 있는 요소들의 개수를, 나머지 슬롯에 정수값을 저장한 배열 물리적으로 동질 구조이지만 논리적으로는 동질 구조가 아님 요소들간에 순서가 존재한다. 존재 배열의 요소는 위치에 의해 접근된다. (index: 0부터 시작) 배열의 용량은 컴파일 시간에 정해진다. 정해진다 배열의 모든 슬롯에 유효한 요소가 들어있을 필요는 없다. 배열의 용량을 변경할 수 없다. 임의 접근 제공: 제공 모든 요소를 바로 접근할 수 있다. 용량이 고정되어 있기 때문에 임의 접근이 가능하다.
배열은 같은 종류의 데이터를 유지하기 위해 사용되는 자료구조이다. 배열은 대부분의 프로 그래밍 언어에서 자체적으로 제공된다. 배열의 가장 큰 특징은 다음과 같다. 첫째, 동질구조이다. 즉, 저장되어 있는 모든 요소가 같은 타입이다. 하지만 배열이라 하여 반드시 같은 종류의 데이터만 저장되는 것은 아니다. 예를 들어 정수 배열에 첫 번째 요소
- 25 -
에 배열에 저장된 요소의 개수를 유지하면 물리적으로는 모두 정수 값이 저장되어 있지만 논리적으로는 첫 번째 요소와 나머지 요소는 다르다. 또 다른 예로 자바에서 Object 타입의 배열을 사용할 경우에는 이 배열에 저장할 수 있는 데이터 타입에 거의 제한이 없다. 하지 만 이런 예외적인 경우는 바람직한 경우가 아니다. 따라서 배열은 동질 구조가 되도록 보장 하는 것이 바람직하다. 둘째, 요소들 간에 순서가 존재한다. 배열은 각 요소를 접근하기 위해 정수 타입의 색인이 라는 정보를 사용하며, 자바는 다른 대부분의 프로그래밍 언어와 마찬가지로 첫 번째 색인 의 값은 0이다. 색인 값에 의해 요소들 간에 순서가 결정된다. 셋째, 배열의 용량은 컴파일 시간에 정해진다. 앞으로 혼란을 줄이기 위해 배열의 용량
(capacity)은 배열에 생성할 때 지정한 배열에 저장할 수 있는 요소의 최대 개수를 나타내는 용어로 사용하며, 배열의 크기(size)는 현재 배열에 저장되어 있는 요소의 개수를 나타내는 용어로 사용한다. 넷째, 배열은 임의 접근을 제공한다. 색인 정보만 있으면 배열 내에서 요소의 위치와 상관 없이 바로 접근할 수 있다. 이것이 가능한 이유는 용량이 컴파일 시간에 고정되어 있으며, 각 요소의 크기가 정해져 있기 때문이다.
일차원 배열 자바에서 배열은 참조 타입이다. 일차원 배열의 선언 예2.2) int[] numbers; // reference type C/C++처럼 int numbers[];와 같이 선언될 수도 있다. 다른 참조 타입과 마찬가지로 new를 이용하여 생성되어야 한다. 예2.3) numbers = new int[10]; C/C++처럼 int numbers[10];과 같이 new를 사용하지 않고, 선언과 동시에 생성하는 것은 가능하지 않다. 선언과 생성을 동시에 할 수 있다. 예2.4) int[] numbers = new int[10]; new를 이용하여 생성된 배열의 초기값은? 기본 값으로 자동으로 초기화된다.
자바에서 배열은 참조 타입으로서 new 연산자를 이용하여 생성된다. 배열을 선언할 때에는 배열임을 나타내기 위해 ‘[]’을 타입명 또는 배열명 뒤에 첨가한다. 보통 타입과 관련된 정보 를 한 곳에서 표현하기 위해 타입명 뒤에 첨가하는 형태를 많이 사용한다. 자바에서는 초기 값을 사용하지 않고 배열을 생성하면 기본 값으로 모든 요소의 값이 자동으로 초기화된다.
- 26 -
일차원 배열 – 계속 배열의 요소 접근: [] 연산자 이용 예2.5) numbers[2] = 5; value = numbers[i]; 색인값이 0에서 9사이의 값이 아니면 자바는 예외를 발생시킨다. 예외: ArrayIndexOutofBoundException 배열의 용량은 public 멤버변수인 length를 이용한다. 예2.6) numbers.length 사용자는 이 변수의 값을 변경할 수 없다. 예2.7) numbers.length = 10; 비고. 비고. String에서 문자열의 길이는 length() 메소드를 이용 numbers length
5/22
자바에서 각 배열의 요소는 []
연산자를 이용하여 접근한다. 유효하지 않은 색인 값을 사용
하면 ArrayIndexOutofBoundException이라는 예외가 발생한다. 자바에서 배열의 용량은 항상
length라는 public 멤버변수를 이용하여 얻을 수 있다. 따라서 논리적으로 배열을 나타내면 이 슬라이드에 있는 그림과 같다. 이 때문에 배열을 인자로 전달하면 그 메소드 내에서 매 개변수를 이용하여 배열의 용량을 알아낼 수 있다. 참고적으로 String 타입에서는 멤버변수 가 아닌 length()라는 메소드를 이용하여 문자열의 길이를 얻을 수 있다.
일차원 배열 – 계속 초기값을 제공하여 생성할 수 있다. 예2.8) int[] numbers = new int[] {5, 7, 4, 3, 10, 22, -6, -3}; 이 때 numbers의 용량은 8이다. 다음과 같이 new int[] 부분을 생략할 수 있다. 예2.9) int[] numbers = {5, 7, 4, 3, 10, 22, -6, -3}; 대입문을 이용하여 초기화하는 것보다 초기값을 이용하는 것이 더 효율적이다. int[] age = {2, 12, 10}; 이 형태가 더 효율적
int[] age = new int[3]; age[0] = 2; age[1] = 12; age[2] = 10;
예2.8) int[] numbers = new int[8] {5, 7, 4, 3, 10, 22, -6, -3};
6/22
배열은 초기값을 제공하여 생성할 수도 있다. 이 때 new 연산자의 사용을 생략할 수 있다. 하지만 초기값 목록을 사용할 때에는 배열의 용량을 지정할 수 없다. 또한 대입문을 이용하 여 초기화하는 것보다 초기값 목록을 사용하는 것이 보다 효율적이다. 그러나 너무 큰 배열 은 초기값을 모두 나열하여 초기화하는 것이 어렵다는 문제점이 있다.
- 27 -
문자 배열과 문자열 문자 배열은 String 타입의 객체가 아니다. 예2.10)
void f1(String s){ … } void f2(){ char[] fruit = {‘a’, ‘p’, ‘p’, ‘l’, ‘e’}; String s = fruit; // error f1(fruit); // error }
다음과 같은 문자 배열을 문자열로 변환할 수 있다. 예2.11) char[] fruit = {‘a’, ‘p’, ‘p’, ‘l’, ‘e’}; String s1 = new String(fruit); String s2 = new String(fruit, 2, 3);
이 처럼 String도 new 연산자를 사용하여 생성할 수 있지만 보통 new를 생략한다. 물론 이 경우에는 생략할 수 없다.
// ok // ok “ple”
문자 배열은 문자열처럼 출력할 수 있다. 예2.12) char[] fruit = {‘a’, ‘p’, ‘p’, ‘l’, ‘e’};
결과: apple
System.out.println(fruit);
7/22
C/C++와 마찬가지로 자바에서도 문자 배열과 문자열은 구분된다. 즉, 문자 배열은 String 타입의 인스턴스가 아니다. 하지만 문자 배열을 쉽게 문자열로 변환할 수 있으며, 문자 배 열은 문자열처럼 출력할 수도 있다. 예2.11에서 알 수 있듯이 new 연산자를 이용하여
String 객체를 생성할 수도 있다. 하지만 보통 String 객체는 new 연산자를 사용하지 않고 생성한다.
배열의 복사 numbers 두 배열 변수가 같은 타입이면 상호 대입할 수 있다. 예2.13) int[] numbers = {1, 2, 3, 4}; int[] values = numbers; values는 numbers와 같은 배열을 참조하게 된다. values 한 배열에 있는 모든 값을 다른 배열로 복사하고 싶으면 System.arraycopy 메소드를 이용한다. 예2.14) int[] numbers = {1,2,3,4,5}; int[] values = {11,12,13,14,15}; System.arraycopy(numbers,0,values,2,3);
numbers
1 2 3 4 5
values
11 12 13 14 15
numbers
1 2 3 4 5
values
System.arraycopy(소스배열, 소스배열의 시작위치, 목적배열, 목적배열의 시작위치, 복사할 요소의 개수)
1 2 3 4
11 12 1 2 3
8/22
자바에서 배열의 이름은 C/C++처럼 상수 포인터가 아니다. 따라서 같은 타입의 다른 배열 변수를 = 연산자를 이용하여 대입할 수 있다. 하지만 이런 대입은 값들이 복사되는 것이 아 니라 변수가 가리키는 배열이 달라질 뿐이다. 배열 변수가 가리키는 위치의 변화 없이 어떤 배열의 값만을 복사하고 싶으면 자바에서는 System 클래스의 arraycopy 메소드를 이용한다.
- 28 -
파라미터로 배열의 전달 자바에서 배열은 참조 타입이므로 인자로 메소드에 전달하여 메소드 내에서 배열을 조작하면 메소드가 끝난 후에도 조작 결과가 계속 유지된다. void f1(int[] a){ 예2.15) a[2] = 10; 결과: 1,2,10,4 } void f2(){ int[] A = {1,2,3,4}; f1(A); void f1(int[] a){ for(int i=0; i<4; i++) int[] B = {5,6,7,8}; System.out.println(A[i]); a = B; 결과: 1,2,3,4 } }
하지만 이 역시 call-by-value 형태로 전달되는 것이다. 예2.16)
void f2(){ int[] A = {1,2,3,4}; f1(A); for(int i=0; i<4; i++) System.out.println(A[i]); } 9/22
자바에서 배열의 이름은 참조 타입이므로 배열을 인자로 다른 메소드에 전달하여 메소드 내 에서 배열을 조작하면 그 결과가 메소드가 끝난 후에도 계속 유지된다. 하지만 자바에서는
call-by-value 방식만 제공하므로 배열을 인자로 전달하여 배열 이름이 가리키는 위치를 변 경할 수는 없다.
Call-by-value와 참조 변수 public class A{ private int value; public void set(int n) { value = n; } public int get() { return value; } } // class A public class B{ public static void f(A b){ A c = new A(); b.set(10); b = c; } // f public static void main(String[] args){ A a = new A(); a.set(5); f(a); System.out.println(a.get()); } // main 결과: 10 } // class B
a
value v 10 5
b
c value
v
10/22 10/22
이 슬라이드의 예에서는 call-by-value와 참조 타입의 관계를 설명하고 있다. 자바에서는
call-by-value 방식의 인자 전달 방법만 제공한다. 따라서 swap 메소드는 자바에서는 절대 구현할 수 없다. 하지만 참조 타입 개념에 때문에 결과가 마치 call-by-reference로 전달한 것과 같은 효과를 얻을 수 있다. f2() 메소드에서 a라는 객체는 a.set(5) 때문에 a의 value 멤버변수의 값이 5가 된다. 이 객체를 f1() 메소드에 전달하면 a는 참조 타입이므로 f1에서
b는 a와 같은 객체를 가리키게 되며, b.set(10) 때문에 a의 멤버변수의 값은 10으로 변경된 다. 하지만 call-by-value 방식이므로 b = c에 의해 b가 c를 가리키게 되더라도 a에는 영향 을 주지 않는다.
- 29 -
배열을 반환하는 메소드 배열을 메소드의 결과로 반환할 수 있다. 예2.17) int[] doubeTheSize(int[] a){ 2 int[] b = new int[a.length*2]; 3 System.arraycopy(a,0,b,0,a.length); 4 return b; void f(){ } int[] A = {1,2,3,4}; 1 2 5 A = doubleTheSize(A); A[4] = 5; 6 b for(int i=0; i<5; i++) System.out.println(A[i]); } 3
a
A 1 2
5
1 2
4
3 4
1
2
3
4
1
2
3
4
6
5 11/22 11/22
배열을 메소드에 인자로 전달할 수 있을 뿐만 아니라 배열을 메소드의 결과로 반환할 수 있 다. 예2.7에서는 doubleTheSize 메소드를 이용하여 기존 배열의 용량을 두 배로 확장하고 있다. 배열의 용량은 컴파일 시간에 결정되어 고정되므로 기존 공간을 두 배로 확장할 수는 없다. 배열의 용량을 확장하기 위해서는 필요한 용량의 공간을 새롭게 확보한 다음, 기존 데이터를 새 공간으로 옮기고, 배열 이름을 새롭게 확보한 공간을 가리키도록 해야 한다.
객체 배열 예2.18) Circle[] allCircles = new Circle[5]; Circle 객체에 대한 참조를 5개까지 저장할 수 있는 배열이 생성된다. 5개의 Circle 객체가 생성된 것은 아니다. 이 때 각 항은 null로 초기화된다. 각 개별 객체를 생성하고자 하면 추가로 다음과 같은 코드를 실행해야 한다. for(int i=0; i<allCircles.length; i++){ allCircles[i] = new Circle(); }
12/22 12/22
원시 타입의 배열이 아닌 클래스 타입의 배열의 경우에는 배열을 생성하였다고 하여 그 만 큼의 객체가 생성되는 것은 아니다. 이 슬라이드의 예처럼 Circle[] allCircles = new
Circle[5]을 하게 되면 5개의 Circle 객체가 생성되는 것이 아니라 Circle 객체에 대한 참조를 5개까지 저장할 수 있는 배열이 생성된다. 이 때 배열의 각 항은 null 값으로 초기화된다.
- 30 -
final과 배열 예2.19)
void f(){ final int[] numbers = new int[5]; numbers[3] = 4; // ok numbers = new int[3]; // error }
final int[] numbers = new int[5]에서 final은 numbers와 연관이 있는 것이지 배열 자체와 있는 것은 아니다.
이 부분만 final
13/22 13/22
자바에서 final이라는 키워드는 주로 상수를 정의하기 위해 사용된다. 예를 들어, 값이 10인 상수 정수 변수는 자바에서는 다음과 같이 선언한다.
public static final int CAPACITY = 10; 배열을 선언할 때 final 키워드를 사용하면 배열 이름 자체가 final이 되어 배열의 이름이 가 리키는 위치를 변경할 수 없게 되지만 배열 이름을 이용하여 여전히 배열의 각 항의 내용은 변경할 수 있다.
다차원 배열 2차원 배열은 행과 열로 구성된 테이블을 나타내기 위해 사용한다. 2차원 배열의 선언 예2.20) double[][] alpha = new double[100][10]; 첫 번째 [100]은 행을 두 번째 [10]은 열을 나타낸다. 2차원 배열의 접근 alpha[0][5] = 36.4; 행과 열의 개수 행의 개수: alpha.length 열의 개수: alpha[i].length
14/22 14/22
자바도 다른 일반 범용 프로그래밍 언어와 마찬가지로 다차원 배열을 정의하여 사용할 수 있다. 하지만 일반적으로 2차원 이상의 배열을 사용하는 경우는 드물다. 또한 자바에서
2차원 배열은 다른 언어에서 2차원 배열과 달리 한 가지 독특한 다음과 같은 특성을 가지고 있다. 자바에서 2차원 배열의 각 행의 용량은 서로 다를 수 있다.
- 31 -
다차원 배열 – 계속 일반적으로 2차원 배열의 각 행의 용량은 같도록 만든다. 예2.21) int[][] a = new int[10][5]; 그러나 각 행의 용량이 다를 수도 있다. 예2.22) int[][] a = new int[2][]; a[0] = new int[5]; a[1] = new int[3];
15/22 15/22
3.3. ArrayList ArrayList는 java.util 패키지에서 제공하는 클래스로서, 배열과 유사하지만 배열과 달리 용량 이 부족하면 자동으로 증가된다. 이 클래스는 자바 1.2부터 제공된 클래스이며, 다중 쓰레드 를 지원하지 않는다.
ArrayList ArrayList는 java.util 패키지에 정의되어 있는 클래스로 배열과 유사하지만 배열과 달리 용량이 고정되어 있지 않다. 않다 즉, 필요에 따라 용량이 변한다. ArrayList는 size() 메소드를 이용하여 현재 저장되어 있는 요소의 개수를 얻을 수 있다. 배열은 특정 타입을 저장하도록 선언할 수 있지만, ArrayList는 Object 타입만 저장할 수 있다. (참조를 저장한다.) 따라서 원시 타입은 wrapper 클래스를 사용해야 한다. 이론적으로 서로 다른 종류의 값을 ArrayList의 요소로 사용 가능 int[] a = new int[10]; a[0] = 3; a[1] = 2.5; // error
ArrayList a = new ArrayList(); a.add(new Integer(10)); a.add(new Double(2.5)); 바람직한 프로그래밍 스타일은 아니다.
ArrayList는 범용 자료구조로서 특정 타입만 저장할 수 있는 것이 아니고, 필요한 어떤 종류 의 데이터도 저장할 수 있다. 이를 위해 내부적으로 Object 배열을 이용한다. 상속 관계에서 자손 클래스의 객체는 조상 클래스 객체 타입에 저장할 수 있다. 그런데 자바에서 Object 클래스는 가장 최상위 조상 클래스이므로 어떤 종류의 객체도 저장할 수 있다. 하지만 int,
double과 같은 원시 타입은 Object 타입에 저장할 수 없다. 따라서 이를 위해 자바는 Wrapper 클래스를 제공한다. Wrapper 클래스는 원시 타입을 객체처럼 다룰 수 있도록 해주 는 클래스이다. Object 배열의 또 다른 문제점은 배열의 동질 구조 특성을 보장하지 못하는
- 32 -
문제점이 있다. 즉, 위 슬라이드에서 보여주는 바와 같이 각 항에 다른 종류의 객체를 저장 할 수 있다. 이렇게 사용하는 것은 매우 바람직하지 않다.
ArrayList – 계속 ArrayList는 클래스이며, 자바는 연산자를 재정의할 수 없으므로 배열과 달리 [ ] 연산자를 이용하여 요소를 접근할 수 없다. ArrayList도 배열과 마찬가지로 색인을 이용하여 저장되어 있는 객체들을 접근할 수 있다. 색인의 시작은 배열과 마찬가지로 0이다. add()와 get() 메소드를 이용한다. void add(Object element): 맨 끝에 요소를 추가한다. void add(int index, Object element): 주어진 색인 위치에 요소를 추가한다. 이 때 색인 위치에 요소가 이미 있으면 그 요소부터 모든 요소는 오른쪽으로 하나씩 이동된다. Object get(int index): 색인 위치에 있는 요소를 반환한다. 만약 index가 유효한 색인(0≤index<size())이 아니면 IndexOutOfBoundsException을 발생한다.
ArrayList는 자바 자체에서 제공되는 데이터 타입이 아니라 자바 표준 라이브러리에서 제공 하는 클래스이다. 또한 자바는 C++ 언어와 달리 연산자를 재정의할 수 없으므로 [] 연산자 를 이용하여 개별 요소를 접근할 수 없다. 대신에 add, get와 같은 메소드를 통해 개별 요 소에 접근한다. 그러나 배열과 마찬가지로 색인 정보를 이용하며, 색인은 일반 배열과 같이
0부터 시작한다. ArrayList는 삽입된 요소들을 왼쪽부터 차례로 저장한다. 즉, 중간에 빈 항 이 존재할 수 없다. 따라서 add 메소드는 기본적으로 주어진 요소를 맨 끝에 삽입한다. 이 것은 기존에 저장되어 있는 요소를 대체하는 방식이 아닐 경우에는 맨 끝에 삽입하는 것이 가장 저렴하기 때문이다.
ArrayList – 계속 항상 ArrayList의 오른쪽은 비어있다. (왼쪽정렬 방식) 중간에 빈 슬롯이 있을 수 없다. 없다 Object set(int index, Object element): 색인 위치에 있는 요소를 주어진 객체로 대체한다. 또한 결과로 이전에 그 위치에 있던 객체를 반환한다. 만약 index가 유효한 색인이 아니면 IndexOutOfBoundsException을 발생한다. Object remove(int index): 색인 위치에 있는 요소를 삭제하고, 그 뒤 에 있는 객체들을 하나씩 왼쪽으로 이동한다. 또한 결과로 삭제한 객체를 반환한다. 만약 index가 유효한 색인이 아니면 IndexOutOfBoundsException을 발생한다. void trimToSize(): ArrayList의 용량을 현재의 크기로 축소한다.
add와 get 메소드 외에 ArrayList는 set, remove와 같은 메소드를 제공한다. set은 주어진 색인 위치에 있는 요소를 주어진 요소로 대체하는 것이고, remove는 주어진 위치에 있는 요소를 삭제한다.
- 33 -
ArrayList – 계속 add/remove 메소드 사용시 주의점 add는 최악의 경우 현재 저장되어 있는 모든 요소를 하나씩 오른쪽으로 이동해야 한다. 반대로 remove는 최악의 경우 현재 저장되어 있는 모든 요소를 하나씩 왼쪽으로 이동해야 한다. 현재 크기가 용량과 같은 경우에는 현재보다 큰 용량의 배열을 생성한 다음에 기존 배열에서 요소들을 복사한다. get 메소드 사용시 주의점 반환타입이 Object이므로 반환값을 원하는 타입으로 강제 변환 해주어야 한다. ArrayList의 생성자 ArrayList() : 용량이 10인 빈 리스트를 생성한다. ArrayList(int capacity): 주어진 인자에 해당하는 용량의 빈 리스트를 생성한다.
앞서 언급한 바와 같이 ArrayList는 중간에 빈 항이 존재할 수 없으므로 add 또는 remove 메소드는 최악의 경우 기존 요소를 모두 하나씩 왼쪽 또는 오른쪽으로 이동해야 한다. add 의 경우 맨 첫 항에 새 요소를 추가하는 경우가 최악의 경우이고, remove도 맨 첫 항에 있 는 요소를 제거하는 경우가 최악의 경우이다. 또한 get, set, remove 메소드의 반환 타입이
Object이므로 이 메소드를 통해 받은 객체는 원래의 타입으로 강제 변환한 후에 사용해야 한다.
반복자 ArrayList는 반복자(iterator)를 가지고 있다. 반복자란 구성요소를 차례로 방문할 때 사용하는 도구를 말한다. 반복자는 기본적으로 다음 두 연산을 제공한다. boolean hasNext(): 더 이상의 요소가 있는지 확인할 때 사용 Object next(): 다음 요소를 얻기 위해 사용 예2.23) void printVector(ArrayList list){ Iterator i = list.iterator(); while(i.hasNext()){ System.out.println(i.next()); } }
20/22 20/22
일반적으로 자료구조는 반복자(iterator)라는 메소드 또는 객체를 제공하여 준다. 반복자는 자료구조에 저장되어 있는 모든 요소를 차례로 방문할 수 있도록 해준다. 일반적으로 반복 자 객체는 hasNext(), next() 두 가지 종류의 메소드를 제공하여 준다. hasNext() 메소드는 더 이상 방문할 요소가 있는지 없는지 검사하는 메소드이며, 이 메소드가 참을 반환할 경우 에는 next() 메소드를 이용하여 다음 요소를 얻을 수 있다. Iterator는 java.util 패키지에 정의 되어 있는 인터페이스이다.
- 34 -
단순 배열 vs. ArrayList 단순 배열 공간 문제가 중요하지 않는 경우 실행시간이 중요한 경우 요구되는 배열의 용량이 프로그램 실행마다 크게 변하지 않는 경우 배열의 크기가 프로그램이 실행되는 동안 변하지 않는 경우 배열에 있는 요소의 위치가 중요한 경우
ArrayList 공간 문제가 중요한 경우 실행시간이 중요하지 않는 경우 요구되는 배열의 용량이 프로그램 실행마다 크게 변하는 경우 배열의 크기가 프로그램이 실행되는 동안 극단적으로 변하는 경우 배열에 있는 요소의 위치가 중요하지 않는 경우 삭제/삽입이 대부분 끝에 이루어지는 경우
ArrayList는 일반 단순 배열에 비해 용량에 제한이 없다는 장점이 있지만 현재 용량이 꽉 차 용량을 늘려야 할 경우에는 많은 비용이 든다. 따라서 항상 배열 대신에 ArrayList를 사용하 는 것은 바람직하지 않다. 즉, 공간 문제가 중요하지 않으면 충분한 공간을 단순 배열로 확 보하여 사용하는 것이 효율면에서 ArrayList보다 좋다. 또한 프로그램이 실행될 때마다 요구 되는 배열의 용량이 변하지 않으면 ArrayList를 사용할 필요가 없으며, 마찬가지로 프로그램 이 실행되는 동안에 배열에 저장되어 있는 요소의 개수가 극단적으로 변하지 않으면 단순 배열을 사용하는 것이 바람직하다.
Vector java.util 패키지에 포함되어 있으며, ArrayList와 그 기능이 매우 유사하다. 가장 중요한 차이점은 Vector는 다중쓰레드를 지원하고 ArrayList는 지원하지 않는다. 따라서 다중쓰레드 환경이 아니면 Vector 대신에 ArrayList를 사용하는 것이 바람직하다. ArrayList와 달리 capacity() 메소드를 이용하여 현재 용량을 얻을 수 있다. Vector에 더 이상 요소를 삽입할 공간이 없으면 지정해 놓은 용량만큼 또는 기본 증가 용량만큼 자동으로 증가한다. 기본 증가 용량은 현재 용량의 두 배이다. Vector() Vector(int capacity) 증가하는 용량을 지정하고 싶으면 벡터를 생성할 때 해야 한다. Vector(int capacity, int increment)
Vector 클래스는 java.util 패키지에서 제공하는 ArrayList와 유사한 클래스로서, 이 클래스는 자바의 초기 버전부터 제공된 클래스이다. 이 클래스는 또한 ArrayList와 달리 다중쓰레드를 지원한다. 뿐만 아니라 Vector는 현재 용량이 꽉 찼을 때 증가하는 정책을 사용자가 지정할 수 있다.
- 35 -
제4장 자바를 이용한 범용 자료구조의 구현 이 장에서는 자바를 이용하여 범용 자료구조를 구현할 때 고려해야 하는 여러 가지 문제점 과 그것의 해결책을 살펴본다.
4.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 자바를 이용한 범용 자료구조의 구현 Object 클래스 equals 메소드 clone 메소드 Object 클래스를 이용한 범용 자료구조의 구현 compareTo 메소드 복사 방식 vs. 참조 방식 clone 메소드 Listable 인터페이스
4.2. 범용 자료구조
범용 자료구조 – 개요 (1) 범용 자료구조란 특정한 데이터 타입만 저장할 수 있는 구조가 아닌 모든 종류의 데이터를 저장할 수 있는 구조를 말한다. 예3.1) Point 데이터만 저장할 수 있는 PointList ADT는 범용 자료구조가 아니다. 범용 자료구조를 자바에서 구현하기 위해서는 다양한 종류의 데이터를 모두 저장할 수 있는 데이터 타입이 필요하다. 이 때 가장 쉽게 생각할 수 있는 것은 Object 클래스이다. 클래스 Object 클래스는 자바에서 가장 최상위 조상 클래스이며, 명백하게 부모를 지정하지 않은 클래스의 부모는 항상 자동으로 Object 클래스가 된다. 객체는 그것의 조상 클래스 타입 변수에 대입할 수 있다. 따라서 모든 객체는 Object 타입의 변수에 저장할 수 있다.
범용 자료구조란 특정한 종류의 데이터만을 저장하기 위해 사용되는 구조가 아니라 다양한 종류의 데이터를 저장하기 위해 사용할 수 있는 자료구조를 말한다. 2장에서 살펴본
ArrayList 클래스는 자바 라이브러리에서 제공하는 범용 자료구조이지만 실습 3에서 구현한
- 37 -
Point 데이터만 저장할 수 있는 PointList ADT는 범용 자료구조가 아니다. 범용 자료구조를 구현하기 위해서는 다양한 데이터 타입을 저장할 수 있는 방법이 필요하다. 자바에서 가장 쉽게 생각할 수 있는 것은 Object 클래스를 사용하는 것이다. 자바에서는 명백하게 부모 클 래스를 지정하지 않으면 Object 클래스가 부모 클래스가 된다. 따라서 Object 클래스는 자 바에서 가장 최상위 조상 클래스이다. 이 때문에 이 타입의 변수에는 어떤 타입의 객체도 저장할 수 있다.
범용 자료구조 – 개요 (2) Object 타입을 이용한 범용 자료구조 구현의 문제점 문제점 1. 원시타입은 Object 타입에 저장할 수 없다. 해결책. 해결책. Wrapper 클래스를 사용한다. 문제점 2. 자료구조마다 그 자료구조에 저장되는 요소가 반드시 제공해야 하는 메소드가 있을 수 있다. 예3.2) 정렬리스트의 경우 저장되는 두 요소의 순위를 비교하는 메소드가 필요함. 예3.3) 자료구조를 만들 때 복사 방식 또는 참조 방식 두 가지 방식으로 사용하여 구현할 수 있다. 이 때 복사 방식을 사용하면 객체를 복제하는 메소드가 필요함 Object 타입을 사용할 경우에는 강제 타입 변환을 하지 않으면 이 클래스에 정의되어 있는 메소드만 호출할 수 있다. Object 타입에 필요한 메소드가 정의되어 있지 않을 수 있다.
자바에서 Object 타입을 이용하여 구현할 경우에는 몇 가지 문제점이 있다. 첫째, 원시타입 은 Object 타입의 변수에 저장할 수 없다. 즉, Object 타입 변수에 Rectangle, Student과 같 은 클래스 타입의 객체들은 저장할 수 있지만 int, double과 같은 원시타입은 저장할 수 없 다. 번거롭지만 이 문제점은 Wrapper 클래스를 이용하여 해결할 수 있다. 둘째, 자료구조마 다 그 자료구조에 저장되는 요소가 반드시 제공해야 하는 메소드가 있을 수 있다. 예를 들 어 정렬 리스트의 경우에는 저장되는 요소들이 정렬된 상태로 저장되어야 한다. 이를 위해 서는 두 요소의 순위를 비교하는 메소드가 필요하다. 그런데 자바에서는 한 타입의 변수를 통해서는 그 타입에 정의되어 있는 메소드만 호출할 수 있다. 즉, Object 타입의 변수이면
Object 클래스에 정의되어 잇는 메소드만 호출할 수 있다. 따라서 자료구조를 구현하기 위 해 Object 클래스에 정의되어 있지 않은 메소드가 필요하면 범용 자료구조를 만들기가 어렵 다. 이 장에서는 이런 상황을 어떻게 해결할 수 있는지 그 방법을 설명한다.
- 38 -
4.3. Object 클래스
Object 클래스 자바에서 다른 클래스를 명백하게 상속받지 않는 경우에는 자동으로 Object 클래스를 상속받는다. 이 클래스는 상속 계층구조 상에서 가장 최상위 조상 클래스이다. Object 클래스는 toString, equals, clone과 같은 유용한 메소드를 제공한다. 이 중 범용 자료구조를 만들 때 많이 사용되는 메소드는 equals와 clone이다. equals 메소드는 두 객체의 내부 상태가 같은지 비교할 때 사용된다. clone 메소드는 상태가 같은 다른 객체를 생성할 때 사용된다. 두 메소드를 재정의(override)하지 않으면 Object 클래스에 정의되어 있는 메소드가 호출된다.
자바에서 Object 클래스는 상속 계층구조 상에서 가장 최상위 조상 클래스이다. 어떤 클래 스를 정의할 때 명백하게 그것의 부모 클래스를 지정하지 않으면 자동으로 Object 클래스가 그것의 부모 클래스가 된다. Object 클래스는 toString, equals, clone와 같은 유용한 메소드 를 제공한다. 이 중에 equals와 clone은 범용 자료구조를 구현할 때 널리 사용된다. equals 메소드는 객체의 내부 상태가 같은지 비교하는 메소드이고, clone은 내부 상태가 같은 객체 를 복제할 때 사용되는 메소드이다. 두 메소드를 재정의(override)하지 않으면 Object 클래스 에 정의되어 있는 메소드가 호출되는데, Object 클래스에 정의되어 있는 이들 메소드는 보 통 우리가 원하는 효과를 제공하지 못한다.
equals 메소드 Object의 equals 메소드의 문제점 Object 클래스에 정의되어 있는 equals 메소드: 두 객체 변수가 같은 객체를 가리키고 있는지 검사한다. 우리가 원하는 equals의 효과는 두 객체의 내부 상태를 비교하는 것이다. 따라서 Object에 정의되어 있는 equals 메소드는 우리가 보통 원하는 효과를 얻을 수 없다. 따라서 클래스를 만들 때 보통 기본적으로 이 메소드는 재정의해야 한다.
equals 메소드는 두 객체의 내부 상태가 같은지 비교하는 메소드이지만 Object에 정의되어 있는 equals 메소드는 두 객체 변수가 같은 객체를 가리키고 있는지 검사하므로 우리가 원 하는 효과를 얻을 수 없다. 따라서 새 클래스를 정의할 때에는 기본적으로 equals 메소드를 재정의해야 한다.
- 39 -
clone 메소드 Object의 clone 메소드의 문제점 Object 클래스에 정의되어 있는 clone 메소드: 이 메소드는 같은 타입의 객체를 자동으로 생성하고 인스턴스 필드의 값을 자동으로 복사한다. (shallow copy) 클래스의 멤버 변수 중 참조 타입이 있으면 문제가 발생할 수 있다. 예3.4) public class logbook{ private int logMonth; private int logYear; private int entry = new int[31]; … }
logMonth
logMonth
logYear
logYear
entry
entry
equals 메소드와 마찬가지로 Object의 clone 메소드는 재정의하여 사용하지 않으면 문제가 발생할 수 있다. Object의 clone 메소드는 같은 타입의 객체를 자동으로 생성하고 인스턴스 필드의 값을 기계적으로 복사하여 객체를 복제한다. 이런 복제를 shallow copy라 한다. 따라 서 클래스의 멤버변수 중 참조 타입이 있으면 복제한 후에 두 객체의 멤버변수가 동일한 것 을 참조하게 되는 문제점이 발생할 수 있다.
boolean equals(Object other) equals 메소드의 구현 사전조건: 사전조건 비교하고자 하는 객체가 null이 아니어야 하며, 같은 클래스의 인스턴스이어야 한다. 사후조건: 사후조건 객체의 내부 상태를 비교하여 내부 상태가 같은 경우에는 true를 반환하고, 그렇지 않으면 false를 반환한다. 같은 클래스의 인스턴스인가를 검사하는 방법 instanceof 연산자: 가능하지만 하위 클래스도 이 연산자를 이용한 검사에서 통과하므로 이 경우에는 사용할 수 없다. Object 클래스의 Class getClass() 메소드 사용 추가적 고려사항 동일 객체인지 검사한다. Æ if(this==other) return true; 인수의 타입이 Object이므로 강제 타입 변환을 한 다음에 각 멤버변수를 비교해야 한다.
Object의 equals 메소드를 재정의할 때 고려해야 하는 사항은 다음과 같다. 첫째, 비교하고 자 하는 객체가 같은 클래스의 인스턴스이어야 한다. 보통 자바에서 객체가 어떤 특정 타입 인지 검사할 때에는 instanceof 연산자를 사용한다. 하지만 instanceof 연산자는 하위 클래스 도 이 검사에 통과하게 되므로 equals 메소드를 구현할 때에는 사용할 수 없다. 이를 위해 이 연산자 대신에 Class 클래스에 정의되어 있는 getClass 메소드를 사용한다. 둘째, 동일 객체가 메소드의 인자로 전달될 수 있다. 셋째, 인자의 타입이 Object이므로 받은 인자를 강 제 타입 변환한 후에 내부 멤버변수들의 상태를 비교해야 한다.
- 40 -
boolean equals(Object other) – 계속 예3.5) public class Date{ private int year; private int month; private int day; … public boolean equals(Object other){ if(other==null||getClass() != other.getClass()) return false; if(this == other) return true; Date o = (Date)other; public class MyDate extends Date{ return year==o.year&& private int val; month==o.month&& … day==o.day; public boolean equals(Object other){ } if(!super.equals(other)) return false; … MyDate o = (MyDate)other; } return val == o.val; } 서브 클래스의 경우
Date라는 클래스에 대해 equals 메소드를 재정의하여 보자. 우선 비교하고자 하는 객체가 null이 아니어야 하며, 객체가 정확하게 Date 클래스의 인스턴스이어야 한다. 즉, Date의 하 위 클래스에 해당되는 객체와는 비교할 수 없다. 비교하고자 하는 객체가 null이 아니고
Date 클래스의 인스턴스이면 주어진 객체가 현재 객체와 같은 객체인지 먼저 검사한다. 이 런 검사가 끝나면 주어진 객체를 Date 객체로 변환한 다음에 Date의 각 멤버변수들의 값이 같은지 비교한다. 부모 클래스에 이미 equals 메소드가 정의되어 있는 경우에는 우선 부모 클래스의 equals 메소드를 호출하여 비교하고, 이 비교에 성공하면 현재 클래스에 추가로 정의되어 있는 멤버변수들의 상태를 비교한다.
Object clone() Object 클래스의 clone 메소드는 객체의 멤버 변수 중에 참조 타입이 있으면 문제가 발생할 수 있다. 따라서 이 메소드는 protected로 선언되어 있다. 즉, 재정의하지 않으면 호출할 수 없다. clone을 재정의하는 경우에는 Cloneable 인터페이스를 구현한다.
멤버변수 중 참조 타입이 있으면 그 변수마다 그 변수의 clone을 호출한다.
public class Customer implements Cloneable{ private String name; private BankAccount account; … public Object clone(){ try{ Customer cloned = (Customer)super.clone(); cloned.account = (BankAccount)account.clone(); return cloned; } catch(CloneNotSupportedException e){ return null; } } }
앞서 언급한 바와 같이 Object의 clone 메소드는 shallow copy 방식의 복제를 하기 때문에 객체의 멤버 변수 중에 참조 타입이 있으면 심각한 문제를 초래할 수 있다. 이런 이유 때문 에 Object의 clone 메소드는 protected 멤버로 선언되어 있다. 따라서 하위 클래스에서
clone 메소드를 public 멤버로 재정의하지 않으면 객체 핸들을 이용하여 직접 호출할 수 없 다. clone을 재정의하는 경우에는 Cloneable 인터페이스를 구현한다고 표기하여, 이 사실을
- 41 -
외부 사용자에게 알려주어야 한다. clone 메소드를 재정의하는 방법은 다음과 같다. 우선 부 모 클래스의 clone을 호출하여 초기 복제를 한다. 그 다음에 참조 타입 때문에 문제가 되는 부분은 shallow copy가 되지 않도록 추가로 복제한다.
protected package pack1; class A{ private int a; public A(int val){ a = val; } protected void f(){ System.out.println(“Ha Ha!!!”); } } class B extends A{ private int b; public B(int val){ super(val); b = val; } public void g(){ f(); } public void test(){ a = 10; // error } }
package pack2; class C{ private int c; public void test(){ A o1 = new A(10); o1.a = 20; // error o1.f(); // error B o2 = new B(20); o2.g(); // ok } } 상속한 클래스가 아닌 관련이 없는 다른 패키지에 있는 클래스의 경우에는 protected는 private과 그 효과가 같다.
clone이 Object의 protected 멤버이므로 자바에세 제공하는 접근 지시자에 대해 복습하여 보 자. 자바는 public, private, protected, default 네 가지 종류의 접근 지시자를 제공하며, 패키 지 개념에 때문에 이들에 대한 개념이 다른 언어에 비해 복잡하다. 이 중에 protected는 상 속과 밀접한 관련이 있다. 위 예에서 B는 A를 상속하고 있으므로 B는 그것의 메소드에서 A 의 protected 멤버인 f()를 접근할 수 있다. 즉, 자식 클래스 내에서는 부모 클래스의
protected 멤버를 public 멤버처럼 접근할 수 있다. 그러나 C는 A를 상속하고 있지도 않으 며, A 또는 B와 같은 패키지 내에 포함되어 있지 않으므로 C 메소드 내에서는 A의
protected 멤버인 f()는 private 멤버처럼 간주되므로 A나 B의 객체 핸들을 이용하여 f() 메소 드를 접근할 수 없다.
- 42 -
4.4. Object 클래스를 이용한 범용 자료구조의 구현
Object 클래스를 이용한 범용 자료구조 요소들을 어떤 기준에 의해 정렬하지 않은 상태로 유지하는 자료구조의 경우에는 삽입은 비교적 쉽게 구현할 수 있으나, 삭제는 내부적으로 요소들이 어떻게 저장되어 있는지와 무관하게 항상 삭제하고자 하는 요소가 현재 구조 내에 있는지 검사해야 한다. Object 클래스를 이용하여 범용 자료구조를 구현할 때에 내부 자료구조 뿐만 아니라 인자로 전달되는 객체를 받기 위해 Object 타입의 파라미터 변수를 사용한다. Object 클래스에 equals 메소드가 정의되어 있으므로 이 메소드를 이용하여 삭제하고자 하는 요소가 있는지 검사할 수 있다. 주의. 주의. Object 타입의 변수에 실제로 저장되어 있는 객체에 equals 메소드가 재정의되어 있지 않으면 우리가 원하는 효과를 얻을 수 없다.
자료구조에 저장하는 요소들이 어떤 기준에 의해 정렬된 상태로 유지될 필요가 없으면 삽입 연산은 비교적 쉽게 구현할 수 있다. 하지만 삭제는 삽입과 달리 요소들이 어떻게 유지되고 있는지와 상관없이 삭제하고자 하는 요소가 자료구조 내에 있는지 항상 검사해야 하므로 상 대적으로 구현하기가 어렵다. Object 클래스를 이용하여 범용 자료구조를 구현하는 경우에 는 보통 내부 자료구조 뿐만 아니라 각 메소드에서 객체를 전달받아야 하는 경우에도
Object 타입의 파라미터 변수를 사용한다. Object 클래스에는 equals 메소드가 정의되어 있 으므로 이 메소드를 이용하여 찾고자하는 요소가 있는지 검사할 수 있다. 물론 이 때
Object 타입의 변수에 저장되어 있는 객체가 equals 메소드를 재정의하고 있지 않으면 우리 가 원하는 효과를 얻을 수 없다.
Object 클래스를 이용한 범용 자료구조 예3.6) public class UnsortedList{ Object[] list; int size = 0; public UnsortedList(int capacity){ list = new Object[capacity]; } public boolean delete(Object item){ for(int i=0; i<size; i++){ if(item.equals(list[i])){ … } } } … }
자바의 late binding 기능 때문에 이 경우 Object에 정의되어 있는 equals 메소드가 아니라 item이 참조하고 있는 객체에 정의되어 있는 equals 메소드가 실행된다.
이 슬라이드의 예에서 delete 연산의 인자 타입은 Object이며, 내부적으로 이 인자의 equals 메소드를 호출하여 삭제하고자 하는 객체가 구조 내에 존재하는지 검사하고 있다. 그러나 실제 인자로 받게 되는 객체는 Object가 아닌 다른 타입일 것이다. 따라서 자바의 late
- 43 -
binding 특성 때문에 Object 클래스에 정의되어 있는 equals 메소드가 아니라 실제 item이 참조하는 객체에 정의되어 있는 equals 메소드가 호출된다.
Object 클래스를 이용한 범용 자료구조 요소들을 어떤 기준에 의해 정렬된 상태로 유지하는 자료 구조의 경우에는 삽입할 때 기존 요소들과 비교하여 삽입 위치를 결정해야 한다. 이런 비교는 자바에서는 보통 compareTo 메소드를 이용한다. Object 타입을 이용하여 범용 자료구조를 구현하고자 할 경우에는 Object 클래스에는 compareTo 메소드가 정의되어 있지 않으므로 예3.6처럼 쉽게 구현할 수 없다. Object 타입 변수에 저장되어 있는 객체에 compareTo 메소드가 정의되어 있어도 Object 타입 변수를 통해서는 compareTo 메소드를 호출할 수 없다. 강제 타입 변환을 하여 호출할 수 있지만 범용 자료구조 구현에서는 어떤 타입의 객체가 변수에 저장될지 모르기 때문에 가능하지 않다.
자료구조에 저장되는 요소들이 어떤 기준에 의해 정렬된 상태로 유지되는 경우에는 삽입할 때 기존 요소들과 비교하여 삽입 위치를 결정해야 한다. 자바에서는 보통 compareTo 메소 드를 이용하여 이런 비교를 한다. 만약 Object 타입을 이용하여 이런 형태의 범용 자료구조 를 구현한다면 Object 클래스에는 compareTo 메소드가 정의되어 있지 않으므로 앞서 살펴 본 예 3.6과 달리 쉽게 구현할 수 없다. 강제 타입 변환하여 compareTo 메소드를 호출하는 방법을 생각해 볼 수 있지만 어떤 타입의 객체가 저장될지 알 수 없으므로 이것은 가능하지 않다.
Object 클래스를 이용한 범용 자료구조 예3.7)
public class Test{ public static void main(String[] args){ public class SortedList{ SortedList list = new SortedList(10); Object[] list; list.insert(new Integer(10)); int size = 0; } public SortedList(int capacity){ } list = new Object[capacity]; } public void insert(Object item){ for(int i=0; i<size; i++){ int comp = item.compareTo(list[i]); // error … } 이 예제에서 Integer 클래스는 } compareTo 메소드가 정의되어 … 있지만 SortedList의 add 메소드는 } 전달된 객체의 타입과 상관없이 item은 Object 타입이므로 item을 이용하여 compareTo를 호출할 수 없다.
이 슬라이드 예제처럼 실제 인자로 전달된 Integer 타입의 객체는 compareTo 메소드를 가 지고 있지만 insert 메소드 내에서 item은 Object 타입이므로 late binding과 상관없이 문범 적으로 compareTo 메소드를 호출할 수 없다.
- 44 -
해결책 1. public class SortedList{ Object[] list; int size = 0; public SortedList(int capacity){ list = new Object[capacity]; } public void insert(Object item){ Comparable x = (Comparable)item; for(int i=0; i<size; i++){ int comp = x.compareTo(list[i]); // ok … } 이 때 item이 Comparable 인터페이스를 구현하고 } 있지 않으면 ClassCastException이 발생한다. … }
compareTo 문제는 위 슬라이드에 있는 예처럼 전달된 인자를 compareTo 메소드를 제공하 는 Comparable 인터페이스 타입으로 강제 타입 변환하여 사용할 수 있다. 이 때 전달된 인 자가 Comparable 타입으로 변환될 수 없으면 ClassCastException 예외가 발생한다. 이 메 소드의 서명만 보면 Comparable 타입의 인자를 전달해야 하는지 알 수 없기 때문에 이 방 법보다는 다음 방법을 사용하는 것이 더 바람직하다.
해결책 2. public class SortedList{ Comparable[] list; // Object[] list; int size = 0; public SortedList(int capacity){ list = new Comparable[capacity]; } public void insert(Comparable item){ for(int i=0; i<size; i++){ int comp = item.compareTo(list[i]); // ok … } } … }
compareTo 메소드 문제를 해결하는 또 다른 방법은 insert의 인자 타입을 Object가 아닌 Comparable 타입으로 선언하여, Comparable 타입 외에는 전달할 수 없도록 하는 것이다. 이 때 내부 배열은 Comparable 배열로 선언할 수 있고, Object 배열로 선언하여 사용할 수 도 있다.
4.5. 복사방식 VS. 참조방식 범용 자료구조를 구현할 때 자바에서는 복사 방식(by clone)과 참조 방식(by reference) 중
- 45 -
어떤 방식으로 구현할지 결정해야 한다. 복사 방식은 주어진 요소를 복제하여 복제된 요소 에 대한 참조를 구조에 유지하는 방식이고, 참조 방식은 주어진 요소에 대한 참조를 그대로 구조에 유지하는 방식이다. 자바에서 객체의 복제는 보통 clone 메소드를 이용한다.
복사방식 vs. 참조방식 복사방식(by clone)
참조방식(by reference)
public void insert(Listable item){ public void insert(Object item){ list[size] = (Listable)item.clone(); list[size] = item; 구현 예 size++; size++; } } 위험성
외부에서 내부 내용을 불법적으로 조작할 수 없다.
외부에서 내부 내용을 불법적으로 조작할 수 있다.
속도
객체가 크고 복잡할수록 느리다.
일정하며, 빠르다.
공간
두 배로 소요된다.
공간이 추가로 필요 없다.
여기서 Listable은 clone 메소드를 public으로 선언하고 있는 인터페이스 타입이다.
참조 방식은 속도 측면이나 공간 측면에서 복사 방식에 비해 성능이 우수하다. 하지만 외부 에서 기존 참조를 이용하여 구조 내에 있는 요소의 값을 변경할 수 있다는 문제점을 가지고 있다.
참조 방식의 문제점 class MyClass{ int value; Myclass(int n){ value=n; } public void set(int n){ value=n; } public int get(){ return value; } … }
list
UnsortedList list = new UnsortedList(5); MyClass a1 = new MyClass(10); MyClass a2 = new MyClass(20); list.insert(a1); list.insert(a2); a1.set(5);
10 5
a1
20
a2
이 슬라이드에 있는 예제에서 볼 수 있듯이 내부 상태가 10과 20인 객체를 list에서 삽입하 였지만 삽입할 때 사용된 a1를 이용하여 외부에서 a1의 값을 조작하면 list에 저장되어 있는 값도 변경된다. 하지만 복사 방식으로 구현되어 있는 경우에는 이런 위험은 없다.
- 46 -
clone clone은 compareTo와 달리 Object 클래스에 존재한다. 하지만 Object의 clone 메소드는 protected 멤버이므로 외부에서는 호출할 수 없다. Object의 clone은 shallow copy(cf. deep copy)를 하기 때문에 객체의 멤버 중 참조 타입이 있으면 문제가 발생할 수 있다. 이 때문에 Object의 clone 메소드는 protected 멤버로 정의되어 있다. 그러면 Cloneable 인터페이스를 이용하여 compareTo 문제를 해결한 것처럼 해결할 수 있을까? 가능하지 않다. Cloneable 인터페이스는 내용이 비어있는 marker interface이다. 참고. 참고. marker interface는 그것을 구현하는 클래스의 특성을 나타낸다. 어떤 메소드를 제공한다는 것을 나타내지 않는다. 예3.8) Serializable: 객체를 파일에 저장할 수 있음을 나타낸다.
clone의 문제점 예3.9)
Cloneable 인터페이스가 clone 메소드를 선언하고 있으면 이 메소드가 public class SortedList{ public 메소드가 되어 호출이 가능하지만 Object[] list; marker interface이므로 가능하지 않다. int size = 0; public SortedList(int capacity){ list = new Object[capacity]; } public Object get(int index) { // return list[index].clone(); Cloneable x = (Cloneable)list[index]; return x.clone(); // error; } … }
해결책. 해결책. 새로운 인터페이스를 정의한다. interface Copyable{ Object clone(); }
public void get(int index) { Copyable x = (Copyable)list[index]; return x.clone(); // ok; }
복사 방식으로 구현할 때에는 Object 타입을 이용하여 범용 자료구조를 만들기가 어렵다.
Object 클래스는 clone 메소드를 제공하고 있으므로, equals 메소드의 예처럼 Object 타입을 사용하여 쉽게 구현할 수 있을 것으로 생각할 수 있다. 하지만 앞서 언급한 바와 같이
clone 메소드는 protected 멤버이므로 Object에 대한 핸들을 이용하여 clone 메소드를 호출 할 수 없다. 그러면 compareTo 메소드 문제를 해결한 것처럼 Cloneable 인터페이스를 이용 하여 해결할 수 있는가? 가능하지 않다. 그 이유는 Cloneable 인터페이스는 불행히도
marker 인터페이스이다. 일반적으로 인터페이스는 어떤 특정 메소드들을 제공한다는 것을 알려주기 위해 사용된다. 하지만 marker 인터페이스는 내용이 빈 인터페이스로서 그것을 구 현하는 클래스가 어떤 메소드들을 제공한다는 것을 나타내지 않고, 어떤 특성을 가진다는 것을 나타내기 위해 사용된다. 그러므로 실제 clone 메소드를 제공하는 객체들만 처리하기 위해서는 별도 인터페이스를 새롭게 정의하여 사용해야 한다.
- 47 -
clone과 compareTo 동시 제공 복사 방식으로 범용 자료구조를 구현하고 싶다. 그런데 이 자료구조는 추가적으로 특정 메소드가 제공되어야 한다. 어떻게 해야 하나? 예3.10) 복사 방식으로 정렬 리스트를 구현하고 싶다. 정렬 리스트를 구현하기 위해서는 compareTo 메소드가 제공되어야 한다. 방법. 방법. Comparable, Cloneable을 구현한 새 인터페이스를 다음과 같이 정의하여 사용한다. 참고. 참고. 인터페이스는 다중으로 인터페이스를 상속할 수 있다. interface Listable extends Comparable, Cloneable{ // int compareTo(Object target); Object clone(); }
복사방식으로 범용 자료구조를 구현하고 싶다. 그러면 앞서 언급한 바와 같이 새 인터페이 스를 정의해야 한다. 그런데 이 자료구조를 구현하기 위해서는 추가적으로 특정 메소드가 제공되어야 한다. 이 경우에는 새롭게 정의한 인터페이스에 이 메소드까지 선언해야 한다. 예3.10처럼 복사방식으로 정렬 리스트를 구현하고 싶으면 Listable이라는 새 인터페이스를 만들고, 이 인터페이스는 Comparable과 Cloneable을 상속받도록 한다. Comparable을 상속 받기 때문에 Listable에 compareTo 메소드는 선언하지 않아도 되지만 Cloneable은 marker 인터페이스이므로 clone 메소드를 선언해주어야 한다. 다음은 이렇게 정의한 Listable 인터 페이스를 이용한 범용 자료구조 구현의 예이다.
clone과 compareTo 동시 제공 예3.11) Listable의 사용 예 class SortedList{ Listable[] list; int size = 0; public SortedList(int capacity){ list = new Listable[capacity]; } public Object get(int index) { return list[index].clone(); } … }
class SortedList{ Object[] list; int size = 0; public SortedList(int capacity){ list = new Object[capacity]; } public Object get(int index) { Listable x = (Listable)list[index]; return x.clone(); } … }
- 48 -
Listable 인터페이스 Listable 인터페이스의 문제점 자바 라이브러리에서 제공하는 Integer와 같은 기존 객체는 저장할 수 없다. 또한 기존 객체를 간단하게 Listable 제공하도록 다음과 같이 만들 수도 없다. class myInteger extends Integer implements Listable{ public myInteger(int val){ super(val); The type myInteger cannot subclass } the final class integer. }
위 문제점에 대한 효율적인 해결책이 없음. 복사 방식의 구현을 포기하거나, 아니면 저장할 객체마다 새롭게 Listable을 구현하도록 새 클래스를 정의함.
Listable 인터페이스를 새롭게 정의하여 사용하면 모든 문제가 부드럽게 해결되는 것은 아니 다. 자료구조에 저장하고자 하는 객체들이 Listable을 구현하고 있어야 한다. 우리가 새롭게 정의하는 데이터들이 Listable을 구현하도록 만드는 것은 어렵지 않지만 기존 객체들이
Listable을 구현하도록 만드는 것은 쉽지 않다. 특히 자바 라이브러리에서 제공되는 클래스 들은 보통 상속받을 수 없는 final 클래스이므로 이것이 매우 어렵다. 따라서 보통은 복사 방식으로 만드는 것을 포기한다.
Object 타입을 이용한 범용 자료구조에 대해 요약하면 다음과 같다. 범용 자료구조를 만들 기 위해서는 다양한 타입을 저장할 수 있는 타입이 필요하다. 자바에서는 Object 클래스가 가장 다양한 타입을 저장할 수 있으므로 보통 이 클래스를 사용하여 범용 자료구조를 구현 한다. 그런데 자료구조마다 그 자료구조에 저장되는 요소가 반드시 제공해야 하는 메소드가 있을 수 있다. 이와 관련하여 Object 타입을 이용할 때 크게 두 가지 경우로 나누어 생각해 볼 수 있다. 첫째, 필요한 메소드가 Object 클래스에 정의되어 있는 경우이다. 둘째, 필요한 메소드가 Object 클래스에 정의되어 있지 않은 경우이다. 전자의 경우 equals처럼 Object에 정의되어 있고, public 메소드이면 비교적 쉽게 구현할 수 있으며, 자료구조 자체보다는 자 료구조에 저장되는 요소들의 클래스를 만들 때 equals를 override하면 된다. 하지만 clone처 럼 정의되어 있어도 protected 메소드이면 이것을 호출할 수 있도록 인터페이스 기능을 이 용해야 한다. 후자의 경우에는 필요한 메소드를 선언되어 있는 인터페이스를 만들거나 이용 하여 해결할 수 있다.
- 49 -
제5장 리스트 이 장에서는 배열을 이용하여 리스트 자료구조를 구현하는 방법을 살펴본다.
5.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 리스트 비정렬 리스트 정렬 리스트 알고리즘 비교 Big-O 표기법
5.2. 리스트
리스트 리스트의 특성 동질 구조: 구조 구조에 있는 모든 요소는 같은 타입이다. 요소들간에 선형 관계가 존재한다. 존재 첫 번째 요소를 제외한 모든 요소는 선행 요소가 있으며, 마지막 요소를 제외한 모든 요소는 후속 요소가 있다. 리스트에 있는 요소의 개수를 리스트의 크기라 한다. 리스트는 정렬되어 유지될 수 있고, 그렇지 않을 수 있다. 비정렬(unsorted) 리스트, 정렬(sorted) 리스트 정렬 리스트에서 요소가 복합 타입일 경우에는 정렬의 기준이 되는 키가 존재한다. 예4.1) 학생 기록부: 학번, 학생 이름, 나이, ... Æ 키는 다양하게 결정될 수 있다.
리스트는 다음과 같은 특성을 갖는 자료구조이다. 첫째, 동질 구조이다. 둘째, 요소들 간에 선형 관계가 존재한다. 선형 관계란 첫 번째 요소를 제외하고는 모두 선행 요소가 있으며, 마지막 요소를 제외하고는 모두 후속 요소를 가진다는 것을 의미한다. 리스트는 요소를 어 떤 기준에 따라 정렬하여 유지할 수 있고, 그렇지 않을 수 있다. 정렬 리스트의 경우에 저
- 51 -
장하는 요소가 복합 타입이면 정렬의 기준이 되는 키가 존재하며, 이 키는 다양하게 결정할 수 있다.
리스트 ADT 구현시 고려사항 중복 허용 여부 동일한 것이 여러 번 삽입될 수 있는지 여부: 응용에 의해 결정 제공해야 하는 연산의 형태 보통 자료구조는 삽입, 삭제, 검색, 추출 연산을 제공한다. 각 연산의 형태는 정해져 있지 않으며, 응용에 따라 다를 수 있다. 예4.2) 삭제 주어진 키 값을 가진 요소를 삭제 자료구조 내에 특정 위치에 저장된 요소를 삭제
리스트 ADT를 구현할 때 먼저 몇 가지 고려해야 하는 사항이 있다. 첫째, 중복 요소를 허 용할 것인지 결정해야 한다. 중복을 허용한다는 것은 동일한 키 값을 가진 여러 요소가 자 료구조에 유지될 수 있다는 것을 말한다. 이것은 응용에 따라 필요할 수도 있고, 그렇지 않 을 수도 있다. 둘째, 제공해야 하는 연산의 형태를 결정해야 한다. 자료구조가 일반적으로 제공해야 하는 연산은 정해져 있다. 보통 삽입, 삭제, 검색, 추출 연산은 기본적으로 제공하 며, 리스트 자료구조도 이들 연산을 제공해야 한다. 그러나 이들 각 연산의 형태는 보통 정 해져 있지 않으며, 응용의 요구에 따라 필요한 형태가 다를 수 있다. 예를 들어 삭제 연산 의 경우에는 어떤 키 값을 가진 요소를 찾아 삭제하는 연산을 제공할 수 있고, 자료구조 내 에 특정 위치에 저장되어 있는 요소를 삭제하는 연산을 제공할 수도 있다. 물론 한 가지 형 태의 삭제 연산을 제공하지 않고, 여러 형태의 삭제 연산을 동시에 제공해야 하는 경우도 있다.
5.3. UnsortedList 이 장에서 배열을 이용한 비정렬 리스트 구현 방법을 살펴본다. 배열은 그 용량이 생성될 때 고정되므로 요소를 삽입하다 보면 더 이상 요소를 삽입할 수 없는 경우가 발생할 수 있 다. 하지만 배열은 색인에 의한 임의 접근을 제공하므로 각 요소를 접근하기가 용이하다.
- 52 -
UnsortedList ADT 배열을 이용한 리스트의 구현 int size; capacity? int cursor; Object[] elements; 생성자 UnsortedList() UnsortedList(int capacity); 상태 boolean isFull(); boolean isEmpty(); int size(); 검색 boolean search(Object item); Object retrieve(Object item);
조작 boolean insert(Object item); boolean delete(Object item); 커서 조작 void reset(); iterator 커서를 처음으로 boolean hasNext(); 후속 요소가 있으면 true를 반환한다. Object next(); 커서가 가리키는 객체를 반환하고, 커서를 다음으로 이동한다.
배열을 이용한 비정렬 리스트의 구현이므로 필요한 멤버변수는 현재 저장되어 있는 요소의 개수를 유지하는 size와 요소들이 저장될 배열 elements가 기본적으로 필요하다. 배열의 용 량 정보도 필요하지만 자바에서는 length라는 배열의 멤버변수를 이용하여 용량 정보를 항 상 얻을 수 있으므로 용량을 유지하는 별도의 멤버변수를 유지할 필요가 없다. 반복자는 이 처럼 클래스 자체에 cursor 멤버변수를 선언하고, reset, hasNext, next 등과 같은 메소드를 정의하여 제공할 수 있고, 자바의 클래스 라이브러리처럼 별도의 반복자 객체를 정의하여 제공할 수 있다. 이 장에서는 우선 클래스 자체에 필요한 메소드를 정의하여 반복자를 제공 한다. 참고적으로 이 장에서 리스트를 구현할 때에는 Object 배열을 이용한다. 따라서 복사 방식이 아닌 참조 방식으로 자료구조를 구현한다. 또한 예외 처리를 하지 않는다.
boolean search(Object item) 사전조건: item!=null 사후조건: item이 리스트에 있으면 true를 반환하고, 없으면 false를 반환한다. 정렬되어 있지 않는 리스트 이므로 선형검색만 가능하다. 검색할 때 종료조건 1. 찾고자 하는 것을 발견함 2. 모든 요소와 비교하였지만 찾지 못함
boolean search(Object item){ int loc = 0; boolean moreToSearch = (item!=null)&&(loc<size); boolean found = false; while(moreToSearch && !found){ if(item.equals(elements[loc])){ found = true; } else{ loc++; moreToSearch = (loc < size); } } return found; } boolean search(Object item){ if(item==null) return false; for(int loc = 0; loc<size; loc++){ if(item.equals(elements[loc])) return true; return false; }
search 연산은 리스트에 주어진 키 값과 같은 요소가 있는지 검사하는 연산으로서, Object 클래스의 equals 메소드를 이용한다. 비정렬 리스트이므로 찾고자 하는 요소가 어디에 있는 지 단정할 수 없다. 따라서 선형검색만 할 수 있다. 선형검색이란 첫 번째 요소부터 끝 요 소까지 차례로 검사하는 방식을 말한다. 선형 검색은 찾고자 하는 것을 발견한 경우와 모든
- 53 -
요소와 비교하였지만 같은 요소가 없는 경우에 완료한다. 따라서 search 연산은 이 두 가지 조건을 각각 나타내는 found와 moreToSearch 지역변수와 while 루프를 이용하여 구현할 수 있다. 이 방법 외에 우리는 배열에 저장되어 있는 요소의 개수를 size라는 멤버변수에 유지 하므로 size와 for 문을 이용하여 보다 간편하게 search 연산을 구현할 수도 있다. retrieve 연산은 search 연산과 반환하는 값만 다를 뿐 동일한 연산이다. retrieve은 주어진 키 값과 같은 요소를 찾아 그 요소를 반환하여 주며, 추출하고자 하는 요소가 없는 경우에는 null 값 을 반환한다.
boolean insert(Object item) 사전조건: item!=null, 리스트가 full이 아님 사후조건: 주어진 item을 리스트에 끝에 추가한다. 추가에 성공하면 true를, 못하면 false를 반환한다.
boolean insert(Object item){ if(item==null || isFull()) return false; elements[size] = item; // Listable x = (Listable)item; // elements[size] = x.clone(); size++; return true; } item elements elements
item
비정렬 리스트에서 삽입 연산은 요소를 특정 위치에 삽입할 필요가 없으므로 ArrayList처럼 리스트 맨 끝에 삽입하는 것이 비용측면에서 가장 저렴하다. 리스트 맨 끝 색인은 현재 리 스트의 크기 정보로부터 쉽게 얻을 수 있다. 배열을 이용한 리스트의 구현에서는 앞서 언급 한 바와 같이 리스트가 꽉 차있으면 더 이상 새 요소를 삽입하지 못한다. 이 장에서는 또한 참조 방식으로 구현하고 있으므로 주어진 새 요소를 복제하지 않고 참조를 배열에 저장한 다. 만약 복사 방식으로 이 구현을 변경하고자 하면 주석처리된 것처럼 주어진 인자를
Listable 타입으로 변환하여 복제한 것을 저장해야 한다.
- 54 -
boolean delete(Object item) 사전조건: 사전조건 item!=null 사후조건: 사후조건 item이 리스트에 있으면 그 item을 삭제한다. 삭제에 성공하면 true를 못하면 false를 반환한다. 검색할 때 종료조건은 search 연산과 같음. 하지만 이 경우에는 두 개의 boolean 변수를 사용하지 않음.
삭제할 것 A
D
C
F
A
D
E
F
E
boolean delete(Object item){ int loc = 0; boolean moreToSearch = (item!=null)&&(loc<size); while(moreToSearch){ if(item.equals(element[loc])){ if(loc!=size-1) elements[loc] = elements[size-1]; size--; 정렬되어 있지 않는 return true; 리스트이므로 가능 } else{ loc++; moreToSearch = (loc<size); } } // while return false; } // delete
삭제 연산은 삽입 연산과 달리 먼저 삭제하고자 하는 요소가 리스트에 있는지 검사해야 한 다. 만약 리스트에 삭제하고자 하는 요소가 존재하면 그 요소를 제거해야 하는데, 이 때 단 순하게 그 항에 null 값을 대입한다고 삭제되는 것은 아니다. 배열을 이용한 리스트 구현에 서는 중간에 빈항이 없어야 한다. 이렇게 하지 않으면 삽입, 검색 등 다른 연산에 나쁜 영 향을 준다. 중간에 빈항이 없도록 요소를 삭제하기 위해 삭제하고자 하는 요소의 모든 후속 요소들을 하나씩 이동하는 방법을 생각할 수 있다. 하지만 리스트가 정렬되어 유지되는 것 이 아니므로 맨 끝 요소를 삭제하는 요소와 대체함으로 저렴하게 삭제할 수 있다. 검색할 때 종료하는 조건은 앞서 살펴본 insert와 같다. 하지만 두 개의 변수를 사용하지 않고, 하나 의 변수만을 이용하여 delete를 구현하고 있다. 이처럼 한 가지 방법으로만 메소드를 구현할 수 있는 것은 아니다.
boolean delete(Object item) – 계속 boolean delete(Object item){ if(item==null) return false; for(int loc = 0; loc<size; loc++) if(item.equals(element[loc])){ if(loc!=size-1) elements[loc] = elements[size-1]; size--; return true; } return false; }
이 슬라이드에서는 boolean 변수를 전혀 사용하지 않고 for문을 이용하여 보다 간편하게 구 현하고 있다.
- 55 -
Object next(), boolean hasNext() 사전조건: 없음 사후조건: 커서가 리스트의 맨 처음을 가리키도록 한다.
사전조건: 없음 사후조건: 현재 커서가 가리키고 있는 요소를 반환하고, 커서를 다음 요소를 가리키도록 이동한다.
void reset(){ cursor=0; }
Object next(){ int loc = cursor; cursor++; return elements[loc]; // Listable x = // (Listable)elements[loc]; // return x.clone(); }
사전조건: 없음 사후조건: 커서가 리스트에 끝을 가리 키고 있으면 false 반환하고, 그렇지 않으면 true를 반환한다. boolean hasNext(){ return (cursor<size);
사용법
}
reset(); while(hasNext()){ next() }
reset(); for(int i=0; i<size(); i++){ next() }
이 ADT에서 반복자는 ADT에서 자체적으로 제공한다. 이 때 cursor라는 멤버변수를 통해 다음에 접근할 요소를 유지한다. 복사 방식으로 구현할 경우에는 next 메소드는 저장되어 있는 요소의 참조 값을 바로 반환하지 않고, 요소를 복제하여 반환해주어야 한다.
5.4. SortedList
SortedList ADT UnsortedList와 제공해야 되는 연산이 유사하다. 리스트가 내부적으로 정렬되어 유지되나 정렬되지 않은 상태로 유지되나 외부에서 보는 관점에서는 차이가 없다. 재사용을 고려 방법1. 방법1. 직접 상속 Æ 논리적으로 SortedList가 UnsortedList의 자식 클래스는 아니다. SortedList is a UnsortedList (?) 논리적으로 상속이 적합하지 않아도 상속을 사용할 수 있지만 다음과 같은 문제가 발생할 수 있다. 예4.3) UnsortedList unsorted; SortedList sorted = new SortedList(10); unsorted = sorted; // ???
SortedList는 앞서 구현한 UnsortedList와 제공해야 되는 연산이 매우 유사하다. 실제 내부적 으로는 SortedList는 요소들을 어떤 기준에 따라 정렬하여 유지해야 하지만 사용하는 측에서 는 UnsortedList와 차이가 없다. 이렇게 유사한 연산들을 제공하는 ADT를 만들 때 코드를 최대한 재사용하는 것이 바람직하다. 자바에서는 크게 세 가지 방식으로 재사용을 고려할 수 있다. 첫째, UnsortedList로부터 직접 상속받아 SortedList를 구현하는 것이다. 하지만 상 속은 논리적으로 상속 관계가 성립하는 경우에만 사용해야 한다. 단순히 코드 재사용 목적 으로 논리적으로 상속 관계가 성립되지 않는 것을 상속하는 것은 바람직하지 않다.
- 56 -
SortedList ADT – 계속 방법2. 방법2. abstract 클래스 사용 List라는 클래스를 만들고, UnsortedList와 SortedList 모두 List의 자식 클래스로 만든다. 이 경우 두 클래스의 공통된 코드는 List에 한번 정의하여 사용할 수 있다. 하지만 search, insert, delete 메소드는 두 클래스의 경우 달라야 하므로 List에서 제공할 수 없다. Æ 추상 클래스 방법3. 방법3. 인터페이스 사용 인터페이스는 코드의 재사용은 아니다. 사용자에게 두 클래스가 공통된 메소드를 제공한다는 것을 알려주는 역할밖에 없음 UnsortedList와 SortedList가 List 클래스 외에 다른 클래스를 상속받아야 하면 인터페이스가 유일한 대안
둘째, 추상 클래스를 사용하는 것이다. 즉, List라는 추상 클래스를 만들고, UnsortedList와
SortedList가 모두 List의 자식 클래스가 되도록 만드는 것이다. 이 경우 두 클래스의 공통된 코드는 List 클래스에 한번만 정의하면 된다. 예를 들어 isFull, isEmpty, size 메소드 등은 정렬 리스트나 비정렬 리스트나 같으므로 이들은 List 클래스에 정의한다. 셋째, 인터페이스 를 사용하는 것이다. 하지만 인터페이스는 코드를 재사용하는 것이 아니므로 이 경우에는 두 번째 방법이 가장 바람직하다. public abstract class List{ public final int DEF_CAPACITY=50; protected Object[] elements; protected int size = 0; protected int cursor = -1; public List(){ … } public List(int capacity){ … } private void setup(int capacity){ … } public boolean isFull(){ return (size >= element.length); } public int size(){ return size; } public boolean isEmpty(){ return (size == 0); } public abstract boolean search(Object item); public abstract boolean insert(Object item); public abstract boolean delete(Object item); public abstract Object retrieve(Object item); public void reset(){ cursor = 0; } public List(){ public boolean hasNext(){ setup(DEF_CAPACITY); return cursor<size; } } public List(int capacity){ public Object next(){ if(capacity>0) setup(capacity); int loc = cursor; else setup(DEF_CAPACITY); cursor++; } return element[loc]; private void setup(int capacity){ } elements = new Object[capacity]; } // List }
List 추상 클래스는 위 슬라이드와 같이 정의할 수 있다.
- 57 -
SortedList ADT public class SortedList extends List{ public SortedList(){ super(); } public SortedList(int capacity){ super(capacity); } public boolean search(Object item){ … } public boolean insert(Object item){ … } public boolean delete(Object item){ … } public Object retrieve(Object item){ … } } // SortedList
List 추상 클래스를 상속받아 구현한 SortedList 클래스는 위 슬라이드와 같다.
boolean insert(Object item) 사전조건: item!=null, 리스트가 full이 아님 사후조건: 리스트가 계속 정렬되어 있도록 주어진 item을 추가한다. 추가에 성공하면 true를, 못하면 false를 반환한다. 방법 1. 삽입할 위치를 찾는다. 2. 삽입할 공간을 만든다. 3. 새 요소를 리스트에 삽입한다. 이 때 맨 앞에, 중간에, 끝에 삽입되는 경우를 고려해야 한다. 맨 앞이나 중간이나 절차는 같다.
public boolean insert(Object item){ int loc = 0; boolean moreToSearch = (loc<size); if(item!=null || isFull()) return false; Comparable x = (Comparable)item; while(moreToSearch){ if(x.compareTo(elements[loc])<0) moreToSearch = false; else{ loc++; moreToSearch = (loc < size); } } // while for(int i=size; i>loc; i--) elements[i] = elements[i-1]; elements[loc] = item; size++; return true; }
정렬 리스트에서 삽입은 비정렬 리스트에 비해 복잡하다. 이것은 주어진 요소를 아무 위치 에 저장할 수 없고 기존 요소들과 비교하여 삽입할 위치를 결정해야 한다. 또한 기존 요소 들 사이에 저장해야 할 경우에는 저장되어 있는 일부 요소들을 이동하여 새 요소가 삽입될 자리를 만들어 주어야 하기 때문이다. 기존 요소들과의 비교는 compareTo 메소드를 사용하 게 된다. 따라서 인자로 주어진 객체는 물론 저장되어 있는 요소들은 Comparable 인터페이 스를 구현하고 있는 클래스의 인스턴스이어야 한다. 위 슬라이드에서 요소를 리스트 맨 끝 에 삽입할 경우에는 loc의 값 때문에 for 문은 실행되지 않는다.
- 58 -
boolean insert(Object item) A
B
A
B
A
B
D
C
E D
E
D
E public boolean insert(Object item){ int loc; if(item!=null || isFull()) return false; Comparable x = (Comparable)item; for(loc=0; loc<size; loc++){ if(item.compareTo(elements[loc])<0) break; for(int i=size; i>loc; i--) elements[i] = elements[i-1]; elements[loc] = item; size++; return true; }
boolean delete(Object item) 사전조건: item!=null, 사후조건: item이 리스트에 존재하면 삭제한다. 삭제에 성공하면 true를, 못하면 false를 반환한다.
A
B
C
D
A
B
D
E
E
public boolean delete(Object item){ int loc = 0, comp = 0; boolean moreToSearch = (item!=null)&&(loc<size); boolean found = false; Comparable x = (Comparable)item; while(moreToSearch && !found){ comp = x.compareTo(elements[loc]); if(comp==0) found = true; else if(comp<0) moreToSearch = false; else{ loc++; moreToSearch = (loc < size); } } // while if(found){ for(int i=loc; i<size-1; i++) elements[i] = elements[i+1]; size--; return true; } else return false; }
삭제 연산은 비정렬 리스트의 삭제와 유사하다. 하지만 이 경우에는 비정렬 리스트처럼 맨 끝 요소를 삭제할 요소와 대체하여 삭제할 수 없고, 삭제할 요소의 오른쪽에 있는 모든 요 소들을 하나씩 왼쪽으로 이동해야 한다.
- 59 -
public boolean delete(Object item){ if(item==null) return false; int loc = 0, comp = 0; boolean found = false; Comparable x = (Comparable)item; for(loc=0; loc<size; loc++){ comp = item.compareTo(elements[loc]); if(comp==0){ found = true; break; } else if(comp<0){ found = false; break; } } // for if(found){ for(int i=loc; i<size-1; i++) elements[i] = elements[i+1]; size--; return true; } else return false; }
public boolean search(Object item){ int first = 0, mid = 0, last = size-1, comp = 0; boolean moreToSearch = true; boolean found = false; if(item!=null || isEmpty()) return false; Comparable x = (Comparable)item; while(moreToSearch && !found){ mid = (first + last) / 2; comp = x.compareTo(elements[mid]); if(comp==0) found = true; else if(comp<0){ last = mid - 1; moreToSearch = (first <= last); } else{ first = mid + 1; moreToSearch = (first <= last); } 사전조건: item!=null, } // while return found; 사후조건: item이 리스트에 있으면 true를, } 없으면 false를 반환한다.
A
B
C
D
E
F
G
H
D
E
F
G
H
D
E
F
G H
first=0 last=7 mid=3 A
B
C
first=0 last=2 mid=1 A
B
C
first=2 last=2 mid=2
요소들이 정렬되어 유지되고 있는 경우에는 선형 검색을 하지 않고 이진 검색을 할 수 있 다. 또한 선형 검색을 하더라도 비정렬 리스트와 달리 중간에 멈출 수 있다. 이진 검색은 한번 비교할 때마다 다음에 비교해야 하는 요소의 개수를 반씩 줄이는 방식이다. 즉, 처음 에 10개의 요소가 있을 때 한번 비교한 후에는 이 중에 5개는 후보에서 제외할 수 있다. 이 슬라이드에 있는 예제는 C를 찾는 경우이고, 다음 슬라이드에 있는 예제는 F를 찾는 경우 이다.
- 60 -
A
B
C
D
E
G
H
A
B
C
D
E
G
H
A
B
C
D
E
G
H
A
B
C
D
E
G
H
first=0 last=6 mid=3 first=4 last=6 mid=5 first=4 last=4 mid=4
first=5 last=4 mid=?
5.5. 알고리즘의 성능 정렬 리스트의 경우에는 이진 검색하여 요소를 찾을 수도 있고, 선형 검색을 하여 요소를 찾을 수도 있다. 즉, 어떤 문제를 해결하는 방법이 오직 하나 존재하는 것은 아니다. 여러 해결책이 존재할 때 어떤 해결책을 사용할 것인지 결정할 수 있어야 한다.
알고리즘 비교 알고리즘의 비교는 보통 성능의 비교 Which one is faster(efficient)? 비교하기 위해서는 비교 기준이 있어야 한다. 가장 단순한 방법: 실행 시간 비교 주어진 입력에 대해 주어진 컴퓨터에서는 A 알고리즘이 B 알고리즘보다는 효율적이라는 것 밖에는 알 수 없다. 수행되는 프로그램 문장 수를 비교 프로그래밍 스타일에 의존한다. 프로그래밍 언어마다 다를 수 있다. 알고리즘의 핵심이 되는 연산의 수행 횟수 비교 검색: 비교 연산 주어진 입력에 대한 함수로 횟수를 나타냄
알고리즘을 비교할 때에는 보통 성능을 비교한다. 성능 외에 필요한 메모리 공간 등 다른 기준으로 알고리즘의 우수성을 비교할 수도 있다. 알고리즘의 성능을 비교하기 위해서는 비 교 기준이 있어야 한다. 가장 단순한 방법은 실행 시간을 비교하는 것이다. 하지만 실행 시 간은 컴퓨팅 환경에 따라 다르기 때문에 절대적인 비교 기준이 되기 어렵다. 수행되는 프로 그램의 문장 수를 비교하는 것도 역시 공평한 비교가 되기 어렵다. 프로그램의 문장 수는 프로그래밍 스타일에 많이 의존하며, 프로그래밍 언어에 따라 다를 수도 있다. 따라서 알고 리즘의 성능을 비교할 때 가장 널리 사용되는 기준은 알고리즘의 핵심이 되는 연산의 수행
- 61 -
회수이다. 예를 들어 검색 알고리즘은 비교 연산이 핵심 연산이 되며, 비교 연산을 얼마나 많이 하느냐에 따라 검색 알고리즘의 성능을 결정된다.
Big-O 알고리즘의 성능 = 핵심 연산의 수행 회수 = f(입력의 크기) Big-O 표기법을 이용하여 함수를 요약하여 표현할 수 있다. 예4.4) f(N)=N4+100N2+10N+50 Æ O(N4) N이 매우 크면 N4가 다른 것을 압도하게 된다. 입력의 크기란? 고려되는 문제의 크기 Æ 예4.5) 리스트에 있는 원소의 개수 예4.6) 리스트에 있는 요소를 파일에 기록하기 open file while(more elements in list) write the next element close file (N±한 요소를 기록하는데 소요되는 시간) + (파일 열고 닫는 시간) O(N)
알고리즘의 성능을 나타낼 때 가장 널리 사용되는 표기법 중 하나는 Big-O 표기법이다. 이 표기법은 핵심 연산의 수행 회수를 입력 크기에 관한 함수로 나타낸다. 여기서 입력 크기란 고려되는 문제의 크기로서 리스트의 경우에는 리스트에 있는 요소의 개수를 입력 크기로 사 용할 수 있다. Big-O 표기법으로 알고리즘의 성능을 나타낼 경우에는 가장 압도하는 요소만 을 사용하여 보통 축약하여 나타낸다.
Common Order of Magnitude O(1): 입력 크기에 전혀 영향을 받지 않는 경우 예4.7) 배열에 한 요소 저장하기 O(logN): 로그 시간 한번에 처리해야 하는 양이 반씩 줄어드는 경우 예4.8) 이진 검색 N logN NlogN O(N): 선형 시간 1 0 1 예4.9) 선형 검색 4 2 8 O(NlogN) O(N2) 8 3 24 O(2N): 지수 시간 32 5 160 O(N!): 계승 시간 256
8
2048
N2
2N
1
2
16
16
256
65536
32768
5년
16777216
don’t ask
알고리즘의 성능을 Big-O 표기법에 따라 몇 개의 그룹으로 나눌 수 있다. O(1)은 상수 시간 알고리즘이라 하며, 이런 알고리즘은 입력 크기에 전혀 영향을 받지 않고 항상 일정한 시간 에 문제를 해결한다. O(logN)은 로그 시간 알고리즘이라 하며, 이런 알고리즘은 이진 검색 처럼 한번에 처리해야 하는 양이 반씩 줄어든다. O(N)은 선형 시간 알고리즘이라 하며, 알 고리즘 성능이 입력 크기에 비례하여 증가한다. 표에서 알 수 있듯이 O(NlogN)까지만 입력 크기와 무관하게 사용할 수 있는 실용적인 알고리즘이라 볼 수 있으며, 그 이상의 알고리즘
- 62 -
은 입력 크기가 조금만 커져도 현실적으로 사용하기가 어렵다.
1부터 N까지 합산하는 알고리즘 알고리즘 1. sum = 0; for(int i=1; i<=n; i++) sum = sum + count; 알고리즘 2. sum = ((n+1) * n)/2; 알고리즘 1은 O(N)이지만 알고리즘 2는 O(1)이다. 하지만 기준이 되는 연산이 다르다. 그러면 항상 알고리즘 2가 알고리즘 1보다 좋은가? N이 매우 작은 경우에는 알고리즘 1은 덧셈 연산만 사용하므로 더 좋을 수 있다. 알고리즘 이해도 측면에서 알고리즘 1이 더 좋을 수 있다. Big-O는 N이 매우 클 경우에 대한 비교 척도이다.
1부터 N까지 합산하는 두 종류의 알고리즘을 Big-O 표기법을 이용하여 나타내보자. 우선 산술 연산을 기본 연산으로 생각하면 알고리즘 1은 N번의 덧셈이 필요하므로 O(N)이며, 알 고리즘 2는 입력의 크기와 상관없이 덧셈, 곱셈, 나눗셈이 각각 한번씩 필요하므로 O(1)이 다. 이 경우 N이 커지면 알고리즘 2가 훨씬 성능이 좋다. 하지만 N이 작은 경우에는 알고 리즘 1이 오히려 더 좋다. 그것은 덧셈이 곱셈 또는 나눗셈에 비해 매우 저렴한 연산이기 때문이다. 즉, Big-O는 요약하여 성능을 나타내므로 N이 매우 클 경우에만 정확하게 적용될 수 있다.
UnsortedList와 SortedList의 비교 연산
UnsortedList
SortedList
size, isFull, isEmpty, reset, next
O(1)
O(1)
search
O(N)
O(N) O(logN) 이진검색
insert 찾기 삽입 전체
O(1) O(1) O(1)
O(N) O(N) O(N)
delete 찾기 삭제 전체
O(N) O(1) O(N)
O(N) O(N) O(N)
이 장에서 살펴본 정렬 리스트와 비정렬 리스트를 Big-O 표기법을 이용하여 비교하여 보자. 검색 연산의 경우에는 정렬 리스트는 이진 검색을 할 수 있으므로 성능이 더 좋다. 하지만 삽입은 정렬 리스트는 삽입할 위치를 찾아야 하므로 비정렬 리스트가 더 좋다.
삭제는 둘
다 삭제하고자 하는 요소를 찾아야 하는 비용이 소요되지만 비정렬 리스트의 경우에는 삭제 자체는 상수 시간에 할 수 있다.
- 63 -
제6장 스택, 큐 이 장에서는 배열을 이용한 스택(stack)과 큐(queue) 자료구조의 구현 방법을 살펴본다.
6.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 스택과 큐의 특성을 살펴보고, 배열을 이용한 스택과 큐의 구현방법을 학습한다.
6.2. 스택
스택 – 논리적 단계 스택의 특성 순서가 있는 동질 구조 가장 최근에 추가된 요소가 맨 위(top)에 있고, 가장 오래 전에 추가된 요소가 맨 밑(bottom)에 있다. LIFO(LastLIFO(Last-InIn-FirstFirst-Out) 구조: 구조 요소의 제거 또는 추가는 스택의 top에서만 이루어진다. 연산 push: 요소를 추가하는 연산 pop: 요소를 스택에서 제거하고, 맨 위 요소를 반환해주는 연산 top: 스택의 변화 없이 맨 위 요소를 반환해주는 연산
스택은 리스트와 마찬가지로 동질 구조이다. 하지만 삽입과 삭제가 항상 같은 위치에서 이 루어지는 구조이다. 동전을 쌓는다고 생각하여 보자. 그러면 동전의 추가는 항상 맨 위에 올려놓을 수밖에 없다. 또한 동전의 제거도 맨 위에서만 가능하다. 이런 형태를 스택이라 한다. 이 때 요소가 추가되고, 삭제되는 위치를 스택의 톱(top)이라 한다. 이런 방식으로 추
- 65 -
가와 삭제가 이루어지기 때문에 스택을 LIFO(Last-In-First-Out) 구조라 한다. 즉, 가장 나중 에 추가된 요소가 가장 먼저 추출되는 구조이다. 스택은 보통 크게 push, pop, top 세 가지 연산을 제공한다. push는 요소를 스택에 추가할 때 사용하는 연산이고, pop은 스택 톱에 있 는 요소를 제거할 때 사용하는 연산이며, top은 스택 톱에 있는 요소를 열람할 때 사용하는 연산이다. push와 pop은 스택의 상태를 변화시키지만 top은 스택의 상태를 그대로 유지한 다.
스택 – 논리적 단계 stack = new Stack()
stack.push(2)
stack.push(3)
3 2
stack.top()
stack.pop()
3
2
2
예외 상황 push: 배열을 이용하여 구현할 경우에는 더 이상 추가할 수 없는 상황이 발생할 수 있다. pop, top: 스택에 요소가 하나도 없을 수 있다.
배열을 이용하여 스택을 구현할 때 다음과 같은 예외 상황을 고려해야 한다. 첫째, 배열의 모든 항에 요소가 있어 더 이상 새 요소를 추가할 수 없는 상황에 push 연산을 시도할 수 있다. 둘째, pop, top 연산은 스택에 아무런 요소도 없을 때 pop 또는 top 연산을 시도할 수 있다.
스택 – 예외 StackOverflowException: 스택이 꽉 차있을 때 push를 시도하면 발생 public class StackOverflowException extends RuntimeException{ public StackOverflowException(){ super(“스택이 꽈 차 있는 상태에서 push를 시도하였음.”); } public StackOverflowException(String msg){ super(msg); } }
StackUnderflowException: 스택이 비어 있을 때 pop 또는 top을 시도하면 발생 public class StackUnderflowException extends RuntimeException{ public StackUnderflowException(){ super(“스택이 비어 있는 상태에서 pop/top을 시도하였음.”); } public StackUnderflowException(String msg){ super(msg); } }
이 장부터는 ADT를 구현할 때 예외적인 상황에 대해서는 새로운 예외를 정의하여 예외를 발생한다. 스택의 경우에는 앞서 언급한 바와 같이 두 가지 종류의 예외가 있을 수 있다. 이를 위해 StackOverflowException과 StackUnderflowException을 정의하여 사용한다. 참고적
- 66 -
으로 새 예외 클래스를 정의할 때 이 예외를 checked 또는 unchecked로 할 것인지에 따라 상속받는 클래스가 다르다. checked 예외이면 Exception 클래스를 상속받고, unchecked 예 외이면 RuntimeException 클래스를 상속받는다. 새 예외 클래스를 정의할 때에는 보통 두 개의 생성자만을 정의한다. 이들 생성자는 getMessage 메소드가 반환할 메시지를 설정해 주어야 한다.
스택 – 논리적 단계 범용 스택의 구성 SortedList의 경우에는 삽입할 위치를 정하기 위해 비교할 수 있어야 했지만 스택은 그렇지 않다. 스택에서는 search와 같은 연산이 필요 없다. Object 클래스만 활용하여도 가능하다. Object 클래스를 이용하여 구현할 경우에는 복사 방식으로 구현하기가 어렵다. 복사 방식으로 구현하고 싶으면 Listable 인터페이스를 사용한다.
스택은 삽입과 삭제 연산이 어떤 비교 없이 위치에 의해 결정되므로 equals, compareTo와 같은 연산이 필요 없다. 따라서 Object 배열을 이용하여 쉽게 구현할 수 있다. 하지만 복사 방식으로 구현하고자 할 경우에는 4장에서 설명한 바와 같이 Listable 인터페이스를 활용해 야 한다. 이 장에서는 Listable 인터페이스를 사용하여 복사 방식으로 각 자료구조를 구현한 다.
ArrayStack ADT 배열을 이용한 스택의 구현 조작 int topindex=-1; void push(Object item); isFull()이 참이면 Object[] elements; StackOverflowException 참고. 참고. size가 필요 없음 발생 생성자 void pop(); ArrayStack() isEmpty()가 참이면 ArrayStack(int capacity); StackUnderflowException 발생 상태 Object top(); boolean isFull(); isEmpty()가 참이면 조건: topindex==elements.length-1 StackUnderflowException boolean isEmpty(); 발생 조건: topindex==-1
배열을 이용한 스택 ADT는 스택의 톱을 나타낼 topindex와 추가된 요소들을 저장할 Object 배열 elements가 필요하다. 하지만 스택에 저장되어 있는 요소의 개수는 topindex로부터 얻 을 수 있기 때문에 별도의 멤버변수를 선언하여 유지할 필요가 없다. isFull과 isEmpty 메소
- 67 -
드는 역시 topindex 정보를 통해 판별할 수 있다. Listable 인터페이스를 활용하여 복사 방식 으로 구현하더라도 이 슬라이드처럼 입력 타입, 출력 타입, 내부 배열 타입은 계속 Object를 사용할 수 있다. push 메소드의 인자 타입을 Listable로 사용하는 것과 Object로 사용하는 것의 차이점은 다음과 같다. Listable 타입을 사용하면 이 클래스를 사용하는 측에서는 이 사실을 알고 보통 Listable 타입의 객체를 전달할 것이다. 하지만 Object 타입으로 되어 있 으면 Listable 타입이 요구되는지 몰라 Listable 타입이 아닌 다른 타입의 객체를 전달할 수 도 있다. 이 측면에서 보면 인자 타입을 Listable로 사용하는 것이 보다 효과적인 방법이지 만 이번 장을 제외하고는 복사 방식으로 구현하지 않기 때문에 인자 타입이나 출력 타입으 로 Object 타입을 사용한다.
push public void push(Object item) throws StackOverflowException{ if(isFull()) throw new StackOverflowException(“Push attempted on a full stack.”); Listable x = (Listable)item; topindex++; elements[topindex] = x.clone(); } topindex = -1
topindex = 0
topindex = 1
topindex = 2
2
2
2
push(2)
push(3)
3
push(5)
3 2
2
3
5
5
3 2
배열을 이용한 스택을 구현할 때 0번째 색인을 스택의 톱으로 유지하면 topindex 정보 자체 를 유지할 필요가 없다. 하지만 매번 추가 또는 삭제할 때마다 모든 요소를 하나씩 이동해 야 한다. 따라서 이런 이동 없이 추가하고 삭제하기 위해서는 비정렬 리스트에서 요소를 추 가하듯이 맨 끝에 추가해야 한다. 따라서 매번 추가할 때마다 topindex 정보를 하나 증가시 킨 후에 그 위치에 추가한다.
- 68 -
push – 계속 topindex = 3
2 push(1)
3 5 1
topindex = 4
2
1 5 3 2
push(7)
3 5 1
topindex = 3
7
2
1 5 3 2
push(8)
pop()
실패
3 5 1
1 5 3 2
7
pop, top public void pop() throws StackUnderflowException{ if(isEmpty()) throw new StackUnderflowException(“Pop attempted on an empty stack”); elements[topindex] = null; // 불필요 topindex--; } public Object top() throws StackUnderflowException{ if(isEmpty()) throw new StackUnderflowException(“Top attempted on an empty stack”); Listable x = (Listable)elements[topindex]; return x.clone(); }
pop 연산은 topindex를 하나 감소만 시키면 된다. 이 때 elements[topindex] = null 문장은 반드시 할 필요는 없다. 하지만 이것은 자바의 garbage collection을 촉진시킬 수 있기 때문 에 사용자의 프로그래밍 스타일에 따라 이렇게 하는 것을 권장하는 경우도 있다.
- 69 -
ArrayList를 이용한 Stack의 구현 일반 배열과 달리 public class ArrayListStack{ 용량이 고정되어 있지 private ArrayList stack; 않다. 따라서 push는 public ArrayListStack(){ stack = new ArrayList(); } 항상 성공하며, public void push(Object item){ stack.add(item); } isFull이 참이 되는 public void pop() throws StackUnderflowException{ 경우가 없다. if(isEmpty()) add는 항상 리스트 throw new StackUnderflowException(“…”); 끝에 추가한다. 따라서 stack.remove(stack.size()-1); 스택을 ArrayList로 } 구현하여도 비용 측면 public Object top() throws StackUnderflowException{ 에서 문제가 없다. if(isEmpty()) throw new StackUnderflowException(“…”); by clone 방식으로 return stack.get(stack.size()-1); 변경할 경우에는 } 다음과 같이 한다. public boolean isEmpty() { return (stack.size() == 0); } public void push(Object item){ public boolean isFull() { return false; } Listable x = (Listable)item; } stack.add(x.clone()); }
스택의 push, pop, top 연산은 비정렬 리스트에서 리스트의 맨 끝 요소를 조작하는 경우에 해당되므로 단순 배열 대신에 이미 개발한 비정렬 리스트나 자바에서 제공하는 ArrayList를 이용하여 구현할 수도 있다.
- 70 -
6.3. 큐
큐 – 논리적 단계 큐(queue)의 특성 순서가 있는 동질 구조 가장 최근에 추가된 요소가 맨 뒤(rear)에 있고, 가장 오래 전에 추가된 요소가 맨 앞(front)에 있다. FIFO(FirstFIFO(First-InIn-FirstFirst-Out) 구조: 구조 요소의 추가는 큐의 끝에 이루어지고, 요소의 제거는 앞에서 이루어진다. 연산 enqueue: 요소를 큐의 끝에 추가하는 연산 dequeue: 큐의 맨 앞에 있는 요소를 제거하고, 그 요소를 반환해주는 연산
큐는 스택과 마찬가지로 삽입과 삭제가 위치에 의해 결정된다. 하지만 스택과 달리 같은 위 치에서 삽입과 삭제가 모두 이루어지는 것이 아니고 삽입과 삭제가 서로 다른 위치에 일어 난다. 버스에 승차하기 위해 일렬로 기다리는 사람들이 바로 전형적인 큐의 모습이다. 이 경우 가장 먼저 승차하는 사람은 줄 맨 앞에 있는 사람이고, 나중에 온 사람일수록 줄 끝에 있게 된다. 따라서 큐에서 삽입은 큐의 맨 뒤에서 이루어지고, 삭제는 맨 앞에서 이루어진 다. 이런 방식으로 요소가 추가되고 삭제되므로 큐를 FIFO(First-In-First-Out) 구조라 한다. 큐는 크게 enqueue, dequeue 두 가지 연산을 제공한다. enqueue는 큐에 요소를 추가할 때 사용하는 연산이고, dequeue는 큐에서 요소를 제거할 때 사용되는 연산이다. 스택과 달리 큐는 pop과 top 연산의 기능을 하나의 연산 dequeue에서 모두 제공한다.
큐 – 논리적 단계 queue = new Queue()
queue.enq(2)
2
queue.enq(3)
3
queue.enq(1)
2
1
3
queue.deq()
2
1
3
예외 상황 enqueue: 배열을 이용하여 구현할 경우에는 더 이상 추가할 수 없는 상황이 발생할 수 있다. dequeue: 큐에 요소가 하나도 없을 수 있다.
배열을 이용한 큐의 구현도 스택과 마찬가지로 배열의 모든 항에 요소가 들어있을 때
enqueue를 시도하면 그것을 처리할 수 없다. 큐에 요소가 하나도 없는 경우에 dequeue를 시도하는 경우에도 정상적으로 처리할 수 없다. 이 두 가지 경우에 해당하는 예외 클래스
- 71 -
QueueOverflowException과 QueueUnderflowException를 정의하여 사용한다. 이것은 스택에 서 예외 클래스를 정의한 것과 유사하게 정의하면 된다.
고정된 Front 설계 방법 front=0, rear=-1 front=0, rear=0
enq(10);
10
enq(20);
10
20
enq(30);
10
20
30
20
30
deq(); 20
front=0, rear=1 front=0, rear=2
front=0, rear=1
30
배열의 첫 번째 슬롯을 항상 front로 고정해서 사용하는 방법 실제 front 멤버 변수가 필요 없음 enq는 항상 size-1 슬롯에 추가하면 된다. rear 멤버 변수도 필요 없음 front, rear, size 중 size 하나만 사용하여 구현 가능 deq의 경우에는 최악의 경우 n-1개의 요소를 하나씩 왼쪽으로 이동해야 한다.
배열을 이용한 큐의 구현에서 큐의 맨 앞 정보와 맨 뒤 정보를 어떻게 유지하느냐에 따라 고정된 front 설계 방법, 유동 front 설계 방법으로 나뉘어진다. 고정된 front 설계 방법에서 는 배열의 0번째 항이 항상 큐의 맨 앞이 된다. 따라서 큐에서 요소가 제거되면 모든 요소 를 하나씩 왼쪽으로 이동해야 하는 번거로움이 있다. 하지만 큐 맨 앞의 위치가 고정되어 있으므로 맨 앞 정보를 별도로 유지할 필요가 없으며, 큐의 맨 뒤 정보도 큐에 있는 요소의 개수를 통해 알 수 있으므로 별도로 유지할 필요가 없다. 즉, 하나의 멤버변수 정보를 이용 하여 큐의 맨 앞, 큐의 맨 뒤, 큐에 있는 요소의 개수를 모두 나타낼 수 있다.
유동 Front 설계 방법 enq(10);
10
deq(); enq(20);
20
enq(30);
20
30
enq(40);
20
30
40
enq(50);
20
30
40
20
30
40
enq(60);
60
front=0, rear=-1 front=0, rear=0 front=1, rear=0 front=1, rear=1 front=1, rear=2 front=1, rear=3 50 front=1, rear=4 50 front=1, rear=0
front가 고정되어 있지 않다. enq, deq 모두 O(1)이다. enq: rear 증가 deq: front 증가 추가적으로 front, rear 멤버 변수를 유지해야 하며, front와 rear 값을 통해 큐에 있는 요소의 개수를 알 수 없으면 추가적으로 size 멤버변수가 필요하다. 예에서 알 수 있듯이 full 상태와 empty 상태에서 front와 rear 값의 차이가 동일함
유동 front 설계 방법에서는 큐의 맨 앞 위치가 계속 바뀐다. 따라서 맨 앞 위치와 맨 뒤 위 치가 모두 변하는 형태이다. 그러므로 맨 앞 정보와 맨 뒤 정보를 별도의 멤버변수를 통해 유지해야 한다. enqueue를 할 경우에는 큐 뒤에 추가하는 것이므로 맨 뒤 정보를 나타내는
rear 값을 하나 증가시켜야 하지만 맨 앞 정보를 나타내는 front 값은 변하지 않는다. 반대
- 72 -
로 dequeue를 할 경우에는 큐 앞에 있는 요소를 제거하는 연산이므로 front 값이 하나 증가 하지만 rear 값은 변하지 않는다. 그런데 이 슬라이드처럼 front와 rear 값을 유지하면 이 두 정보를 통해 큐에 있는 요소의 개수를 파악할 수 없다. 이것은 큐가 비어있는 상태와 큐가 꽉 찬 상태가 이 두 정보를 기준으로 보면 같기 때문이다. 즉, 유동 front 설계 방법은 큐의 맨 앞, 큐의 맨 뒤, 큐에 있는 요소의 개수를 나타내는 세 가지 멤버변수를 각각 유지해야 한다. 결론적으로 고정 front 설계 방식은 하나의 멤버변수만 유지하면 되지만 dequeue 연 산은 최악의 경우 O(n) 비용이 필요하다. 반대로 유동 front 설계 방식은 보통 3개의 멤버변 수를 유지해야 하지만 enqueue와 dequeue를 모두 O(1)의 비용으로 처리할 수 있다.
ArrayQueue ADT 배열을 이용한 유동 front 방식의 큐 구현 int front=0; int rear=-1; int size=0; Object[] elements; 생성자 ArrayQueue() ArrayQueue(int capacity);
상태 boolean isFull(); 조건: size==elements.length boolean isEmpty(); 조건: size==0 조작 void enq(Object item); isFull()이 참이면 QueueOverflowException 발생
Object deq(); isEmpty()가 참이면 QueueUnderflowException 발생
이 절에서는 배열을 이용하여 큐를 구현해 본다. 이 때 유동 front 방식을 사용하며, Object 배열을 이용하지만 복사 방식으로 구현하고, 예외 처리를 한다. 다시 한번 언급하지만 유동
front의 경우에는 일반적으로 큐의 맨 앞 정보와 맨 뒤 정보를 이용하여 큐에 있는 요소의 개수를 알 수 없다. 따라서 isFull과 isEmpty 메소드는 size 멤버를 이용하여 구현해야 한다.
Enqueue public void enq(Listable item) throws QueueOverflowException{ if(isFull()) throw new QueueOverflowException(“Enqueue attempted on a full queue.”); rear = (rear + 1) % elements.length; elements[rear] = item.clone(); size++; } 10 deq();
20
30
40
50
20
30
40
50
deq(); enq(55);
55
enq(70);
55
70
30
40
30
40
30
40
front=0, rear=4 front=1, rear=4
front=2, 50 rear=4 front=2, 50 rear=0 50
front=2, rear=1
30 deq();
40
front=2, rear=3 front=3, rear=3 front=4, rear=3 front=4, 55 rear=4
deq(); enq(55); enq(70);
40
65
55 front=4, rear=0
enqueue 연산은 새 요소를 큐 끝에 추가하는 연산이다. 이 연산이 성공하기 위해서는 우선
- 73 -
큐가 꽉 찬 상태가 아니어야 한다. 큐가 꽉 찬 상태가 아니면 큐 끝 정보를 하나 증가시킨 후에 그 위치에 새 요소를 복제하여 추가한다. enqueue 연산은 큐 앞 정보에는 아무런 영 향을 주지 않는다.
Dequeue public Object deq() throws QueueUnderflowException{ if(isEmpty()) throw new QueueUnderflowException(“Dequeue attempted on an empty queue”); Object tmp = element[front]; elements[front] = null; // 불필요 front = (front + 1) % elements.length; size--; return tmp; // tmp.clone() 불필요 } int loc = front; front = (front + 1) % elements.length; size--; return elements[loc];
dequeue 연산은 큐 맨 앞에 있는 요소를 제거하는 연산이다. 따라서 현재 큐 앞 정보에 있 는 요소를 임시 보관한 후에 큐 front 정보를 하나 증가시키고, 임시 보관하였던 요소를 반 환하면 된다. 요소 자체를 임시 보관하는 대신에 그 위치를 보관하여 처리할 수 있지만 성 능이나 공간 활용 측면에서 두 방법은 차이가 없다.
효율적인 유동 front 구현 front와 rear 정보만을 이용하여 현재 큐의 상태(full 또는 empty)를 판별할 수 있는가? 현재 구현의 경우에는 두 조건이 같다. 두 조건이 같으므로 이 조건을 구분하는 boolean flag을 사용한다. size를 사용하는 것과 차이가 없음 public void enq(Object item) throws QueueOverflowException{ if(isFull()) throw new QueueOverflowException(“…”); Listable x = (Listable)item; rear = (rear + 1) % elements.length; elements[rear] = x.clone(); isFull = ((rear + 1) % elements.length)==front)? true : false; }
배열의 한 공간을 사용하지 않으면 판별할 수 있다. (How?)
앞서 언급한 바와 같이 유동 front 설계 방식에서는 큐 맨 앞 정보와 맨 뒤 정보만을 이용하 여 큐의 상태를 판별하기가 어렵다. 이것은 큐가 비어있는 상태와 큐가 꽉 찬 상태에서 큐 의 맨 앞 정보와 맨 뒤 정보의 상태가 같기 때문이다. 이것을 구별하기 위한 별도의 flag을 유지하는 방법을 생각할 수 있지만 이것은 크기 정보를 추가로 유지하는 것에 비해 공간적 인 이점이 없다. 그런데 배열의 한 공간을 늘 사용하지 않으면 별도의 flag을 사용하지 않고 두 상태를 판별할 수 있다.
- 74 -
효율적인 유동 front 구현 – 계속 이 방법은 size 정보를 유지하지 않아도 된다. 하지만 항상 배열 한 슬롯이 사용되지 않는다. 공간 측면에서는 기존 size를 사용하는 것과 차이는 없다. 시간 측면에서는 size를 deq(); 유지하는 비용이 없으므로 효율적이다.
front=5, rear=5 front=5, rear=0
10 20
30
40
50
front=5, rear=4
20
30
40
50
front=0, rear=4
deq();
30
40
50
front=1, rear=4
enq(60);
30
40
50
10
60
front=1, rear=5
배열의 한 항을 늘 사용하지 않고, 큐의 맨 앞 정보가 큐의 맨 앞을 가리키는 대신에 바로 전 위치를 가리키도록 하면 별도의 flag을 사용하지 않고 큐의 맨 앞 정보와 맨 뒤 정보를 이용하여 큐의 상태를 판별할 수 있다. 하지만 이 경우에도 늘 한 공간을 낭비하게 되므로 기존의 요소의 개수를 유지하는 방식에 비해 별로 향상된 점이 없다.
- 75 -
제7장 연결구조 이 장에서는 연결구조를 이용한 각 종 자료구조의 구현에 대해 살펴본다.
7.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 연결구조 연결구조를 이용한 스택의 구현 연결구조를 이용한 큐의 구현 연결구조를 이용한 순환 큐의 구현 연결구조를 이용한 정렬 리스트의 구현 연결구조를 이용한 비정렬 리스트 구현
7.2. 연결구조
연결구조란 연결구조란 연결구조 고정된 공간을 미리 확보하여 사용하지 않고, 필요할 때마다 동적으로 확보하여 사용하는 구조를 구조 말한다. 배열을 이용한 구현 방식에서는 요소를 저장할 공간을 미리 확보하며, 이 공간이 고정되어 있다. 따라서 공간이 낭비되거나 부족할 수 있다. ArrayList처럼 부족하면 보다 큰 공간을 확보하여 새 공간에 기존 요소를 복사하여 사용하는 방식을 취할 수 있다. 이 방식은 공간이 부족할 때 많은 비용이 소요된다는 문제점이 있다. 연결구조는 하나의 요소를 저장할 때마다 공간을 확보한다. 이렇게 확보한 공간을 연결하지 않으면 저장한 요소들을 접근하기 어렵기 때문에 배열과 달리 하나의 요소를 저장할 때마다 추가 공간이 필요하다. 이것이 연결구조의 한 가지 단점이다. 필요 info
info
info
info
info
info
배열방식
info
info
연결 구조 방식
연결구조는 지금까지 살펴본 배열을 이용한 구현과 달리 고정된 공간을 미리 확보하여 사용 하지 않고 필요할 때마다 동적으로 확보하여 사용한다. 따라서 연결구조 방식에서는 배열을 이용한 구현에서 발생할 수 있는 공간 낭비 또는 부족 문제는 발생하지 않는다. 물론 배열 도 공간 부족 문제를 극복하기 위해 ArrayList처럼 부족할 때마다 보다 큰 공간을 확보하여
- 77 -
해결할 수 있지만 큰 공간을 확보하여 기존 정보를 이동하는 비용이 매우 크며, 공간 낭비 문제는 여전히 존재한다. 연결구조는 하나의 요소를 추가할 때마다 새롭게 공간을 확보하기 때문에 이들을 연결하지 않으면 저장되어 있는 요소들을 접근하기가 어렵다. 따라서 하나의 요소를 저장하기 위해 기존 배열보다 두 배의 공간이 필요하다.
연결구조 vs. 배열을 이용한 구현 방식
공간부족/낭비 문제 한 요소가 차지하는 공간 공간 확보 비용
각 요소 접근 방식
중간에 요소 삽입
연결구조
배열을 이용한 구현 방식
없음
있음
요소+연결 새 요소를 삽입할 때마다 확보해야 함 순차 정렬 구조의 경우에도 이진 검색을 할 수 없음 연결만 변경 (상수 시간)
요소 자료구조를 처음 생성할 때 한번 확보 임의 정렬 구조의 경우에는 이진 검색 가능 정렬 구조일 경우에는 후속 요소들을 모두 이동해야 함.
연결구조와 배열을 이용한 구현 방식은 서로 장단점을 가지고 있다. 공간 부족과 낭비 문제 측면에서 연결구조가 우수하지만 연결구조은 한 요소가 차지하는 공간이 배열보다 많다. 배 열은 처음 생성할 때 한번 확보하는 반면에 연결구조 새 요소를 삽입할 때마다 확보해야 하 므로 공간 확보 비용 측면에서는 배열이 우수하다. 배열의 가장 큰 장점은 색인에 의한 임 의 접근을 제공한다는 것이다. 반면에 연결구조의 가장 큰 단점은 순차 접근만 가능하다는 것이다. 하지만 연결구조는 중간에 있는 요소의 삽입이나 삭제는 연결 정보만 변경하여 가 능한 반면에 배열을 이용한 정렬 리스트 구현에서는 중간 이후에 모든 요소를 이동해야 하 는 문제점이 있다. 이처럼 어느 한 방식이 항상 우수하다고 단정할 수 없다. 따라서 응용에 따라 어떤 방식의 구현을 사용할지 결정해야 한다.
- 78 -
7.3. 스택
Stack과 Queue의 Class Hierarchy Stack
ArrayStack
LinkedStack
use inner class
StackNode
LinkedQueue
use inner class
QueueNode
Queue
ArrayQueue
6장에서 배열을 이용한 스택과 큐 구현을 살펴보았다. 이 절에서는 연결구조를 이용한 스택 과 큐의 구현을 살펴보며, 이들 클래스 간에 관계는 이 슬라이드와 같다. 여기서 Stack과
Queue는 인터페이스이다. 배열을 이용한 스택의 구현과 연결구조를 이용한 스택의 구현은 둘 다 같은 종류의 연산을 제공해야 하지만 내부적으로 전혀 다르게 구현되므로 코드를 재 사용할 수는 없다. 그러므로 인터페이스 대신에 추상 클래스를 사용할 수 없다.
배열로 스택의 구현 배열을 이용한 리스트, 스택, 큐의 문제점 배열의 크기가 생성할 때 고정됨 Æ 공간의 낭비, 공간의 부족 배열을 이용한 스택의 물리적 뷰 vs. 논리적 뷰 ArrayStack stack = new ArrayStack(4); stack
topIndex element
-1 null
null
null
null
null
null
null
stack.push(new Integer(10)) stack
topIndex
0
element
10
- 79 -
10
연결구조로 스택의 구현 연결구조 info
link
info
A
link
info
B
null
private class StackNode{ public Object info; public StackNode link; }
C
연결구조로 스택의 구현 stack.push(new Integer(20));
LinkedStack stack = new LinkedStack(); stack
top
null
stack
top
stack.push(new Integer(10)); stack
top
info
null
10
info
info
20
10
null
연결구조 방식에서 하나의 요소를 저장하기 위해서는 그 요소를 저장할 공간과 다른 요소들 과 연결하기 위한 연결 정보를 위한 공간이 필요하다. 이 두 공간을 합쳐 연결구조의 노드 라 한다. 따라서 연결구조 방식의 자료구조를 구현하기 위해서는 연결구조의 노드를 나타내 기 위한 타입이 필요하다. 자바에서는 보통 내부 클래스를 이용하여 노드 타입을 정의한다. 이 타입에는 두 가지 멤버 변수가 필요하다. 하나는 실제 요소를 저장할 변수이고, 다른 하 나는 다음 노드를 연결하기 위한 변수이다.
LinkedStack ADT 연결구조를 이용한 스택의 구현 StackNode top = null;
조작 void push(Object item);
연결 구조이므로 배열과 달리 색인이 아닌 참조 타입으로 스택 top을 유지함
StackOverflowException이 발생하지 않음
void pop();
생성자 LinkedStack() 상태 boolean isFull(); 조건: 항상 거짓 boolean isEmpty(); 조건: top==null
isEmpty()가 참이면 StackUnderflowException 발생
Object top(); isEmpty()가 참이면 StackUnderflowException 발생 stack
top
null
스택에서 가장 중요한 정보는 스택 톱을 나타내는 정보이다. 배열을 이용한 구현에서는 배 열의 특정 색인이 스택 톱 역할을 하였지만 연결구조에서는 연결구조의 한 노드를 가리키는 참조 값이 이 역할을 하게 된다. 또한 배열을 이용한 구현에서는 배열에 있는 모든 항에 요 소가 저장되어 있으면 더 이상 새 요소를 추가할 수 없었다. 하지만 연결구조에서는 시스템 에서 더 이상 허용하지 않을 때까지 계속 새 요소를 위한 공간을 확보하여 사용할 수 있다. 따라서 연결구조에서 isFull 메소드는 보통 항상 false를 반환하도록 정의하며, 새 요소를 삽 입하는 연산은 OverflowException의 발생을 고려하지 않는다.
- 80 -
Inner Class public class LinkedStack implements Stack{ private class StackNode{ public Object item; public StackNode link; } private StackNode top = null; public StackNode() {} … }
클래스 내부에 정의한 클래스를 내부 클래스(inner class)라 한다.
class public class private class
private 내부 클래스는 그것의 외부 클래스를 제외하고는 접근할 수 없으므로 내부 클래스의 멤버 변수를 public으로 선언하여도 안전하다.
public class
패키지
자바에서는 연결구조의 노드를 보통 내부 클래스로 구현한다. 이것은 연결구조의 노드는 해 당 클래스 내에서만 필요한 정보이며 외부에서는 접근할 필요가 없는 정보이기 때문이다. 일반적으로 외부에서 접근할 필요가 없는 정보들은 외부로부터 숨기는 것이 바람직하다. 자 바에서 private 내부 클래스는 다른 패키지에 있는 클래스는 물론 같은 패기지 내에 있는 다 른 클래스들로부터 숨길 수 있다. 따라서 연결구조의 노드를 나타내는 클래스는 내부 클래 스로 정의하여 사용하는 것이 바람직하다. 같은 이유에서 private 내부 클래스의 멤버들은
public으로 지정하여도 안전하다.
Inner Class 내부 클래스를 사용하는 목적 내부 클래스는 그것을 생성한 외부 클래스 객체의 구현 (멤버 변수, 메소드)을 접근 권한과 무관하게 모두 접근할 수 있다. 같은 패키지에 있는 다른 클래스로부터 숨길 수 있다. (name control 측면) 연결 구조에서는 이 측면 때문에 사용 private 내부 클래스를 사용하여 사건 기반 프로그램을 작성할 때 매우 유용하다. top level class
당연한 특성 inner class
외부 클래스 객체의 구현을 접근할 수 있다. static inner class인 경우에는 가능하지 않다.
참고적으로 자바에서 내부 클래스 객체는 그것의 외부 클래스 객체의 멤버 변수와 메소드를 접근 권한과 상관없이 접근할 수 있다. 또한 외부 클래스 객체는 자신의 멤버인 내부 클래 스 객체의 모든 멤버를 접근할 수 있다.
- 81 -
public class Test{ public static void main(String[] args){ public class Outer{ Outer obj = new Outer(2, 3); private int oVal; obj.g(3); private Inner X; obj.print(); private class Inner{ } private int iVal; } public Inner(int n){ 결과: oVal = 6, iVal = 9 iVal = n; } obj public void f(int n){ iVal = oVal + n; // Outer.this.oVal+n oVal X } 2 내부 클래스는 외부 클래스의 } // class Inner private 멤버를 접근할 수 있다. public Outer(int n1, int n2){ oVal = n1; X = new Inner(n2); } 외부 클래스는 내부 클래스의 public void g(int n){ private 멤버를 접근할 수 있다. 3 oVal = X.iVal + n; X.f(n); iVal Outer } public void print(){ System.out.println("oVal = "+oVal+", " 내부 클래스의 객체가 생성될 때 +"iVal = "+X.iVal); 이 객체의 외부 객체에 대한 참조는 } 현재 객체의 this값으로 설정된다. } // class Outer
Outer 클래스의 객체 obj가 생성되면 obj는 멤버로 내부 클래스 타입인 X를 가지게 된다. 이 obj는 private으로 선언되어 있음에도 불구하고 X의 멤버 변수인 iVal을 접근할 수 있다. 이 때 X 핸들을 이용하여 iVal을 접근하게 된다. 반대로 obj의 멤버인 X는 자신의 내부 메 소드에서 자신을 포함하고 있는 외부 객체 obj의 private 멤버인 oVal을 단순 이름으로 접근 할 수 있다. 내부 클래스 객체는 자신을 포함하는 외부 객체에 대한 포인터를 가지고 있으 며, 자신을 포함하는 외부 객체는 오직 하나이므로 단순 이름을 사용하더다로 모호하지 않 다.
push push 알고리즘 단계 1. 새 요소를 위한 노드 생성 단계 2. 이 요소의 info 값에 item 할당 단계 3. 이 요소의 link가 기존 top을 가리키도록 함 단계 4. top이 이 요소를 가리키도록 함 stack
public void push(Object item){ StackNode newNode = new StackNode(); newNode.info = item; newNode.link = top; top = newNode; }
top
4 1. 생성 info
info
null
3 2
10
20
배열을 이용한 스택 구현에서는 새 요소를 항상 맨 끝에 삽입하였다. 하지만 연결구조 방식 에서는 가장 빠르게 접근할 수 있는 요소는 첫 요소이다. 따라서 연결구조을 이용한 스택 구현에서는 새 요소를 첫 요소로 삽입한다. 따라서 연결구조 방식의 구현에서 첫 요소가 스 택의 톱이 된다. 연결구조 방식의 자료구조 구현에서 요소 삽입과 관련된 메소드에서 일반 적으로 가장 먼저 하는 일은 새 요소를 나타낼 노드를 생성하는 일이다. 또한 연결구조 방 식으로 구현할 때 가장 주의해야 하는 것을 연결을 변경하는 순서이다. 예를 들어 위 예에
- 82 -
서 newNode.link = top 문장과 top = newNode 문장의 순서가 바뀌면 기존 요소들과의 연 결이 끊어지게 된다.
pop pop 알고리즘 단계 1. 스택이 비어 있는지 확인함 단계 2. top이 top.link을 가리키도록 함
stack
top
public void pop() throws StackUnderflowException{ if(isEmpty()) throw new StackUnderflowException(“…”); top = top.link; }
1 info
info
20
10
null
garbage
연결구조의 첫 요소가 스택의 톱이 되므로 pop 연산은 첫 요소를 제거하면 된다. 즉, 첫 요 소를 스택 톱으로 사용해야 push와 pop 연산을 모두 효율적으로 처리할 수 있다.
top public Object top() throws StackUnderflowException{ if(isEmpty()) throw new StackUnderflowException(“…”); return top.info; }
stack
top
info
info
null
10 20
top 연산은 스택의 상태를 변경하지 않고 스택의 톱에 있는 요소를 반환하면 된다.
- 83 -
ArrayStack VS. LinkedStack ArrayStack
LinkedStack
push
O(1)
O(1)
pop
O(1)
O(1)
constructor space
O(N)
O(1)
N+1 참조, 1 색인
2N+1 참조 (no waste)
Which one is better? 상황에 따라 다르다. 공간 활용 측면에서 LinkedStack이 우수 스택의 크기가 매우 가변적인 경우 LinkedStack이 우수 ArrayStack은 공간 낭비 가능 push가 빈번하게 일어나는 경우: ArrayStack 우수 LinkedStack은 매번 새 공간을 확보해야함
배열을 이용한 스택의 구현과 연결구조 방식의 스택 구현을 비교하여 보자. 위 표에서 알 수 있듯이 두 구현 모두 push와 pop은 상수 시간 알고리즘이다. 하지만 배열은 공간을 초 기에 모두 확보하는 반면 스택은 새 요소를 추가할 때마다 확보한다. 공간적인 측면에서는 배열에 N개의 요소가 저장되어 있다면 N+1 참조와 스택의 톱 정보를 나타내는 색인 정보 가 필요하다. 하지만 배열은 초기에 공간을 확보하고, 이 공간이 고정되어 있으므로 낭비가 되는 공간이 있을 수 있다. 반대로 연결구조를 이용한 구현에서 N개의 요소가 저장되어 있 다면 2N개의 공간이 필요하며, 스택의 톱 정보를 유지하는 참조 타입의 변수가 추가로 필 요하다. 하지만 이 공간들 중에 낭비가 되는 공간은 없다. 이런 측면에서 보면 어느 구현 방식이 절대적으로 우수하다고 말할 수 없다. 상황에 따라 구현방식을 결정해야 한다.
- 84 -
7.4. 큐
배열로 큐의 구현 배열을 이용한 큐의 물리적 뷰 vs. 논리적 뷰
ArrayQueue queue = new ArrayQueue(4); queue
0
front
rear null
element
-1
null
0
size null
null
queue.enq(new Character(‘A’)) queue
0
front
0
rear
null
element
front
rear 1
size null
A
null
A
queue.enq(new Character(‘B’)) queue
0
front
1
rear
A
2
size null
element
front
rear
B
null
A
B
연결구조로 큐의 구현 연결구조로 큐의 구현 LinkedQueue queue = new LinkedQueue(); queue
front
null
rear null
size
0
size
1
private class QueueNode{ public Object info; public QueueNode link; }
queue.enq(new Character(‘A’)); queue
front
info A
rear
null
queue.enq(new Character(‘B’)); queue
front
size
rear
info
info
A
B
2
null
연결구조를 이용한 큐의 구현도 스택의 구현과 마찬가지로 연결구조의 노드를 나타내기 위 한 클래스를 정의해야 한다. 이 클래스 역시 내부 클래스로 정의한다. 연결구조를 이용한 스택의 구현과 달리 큐는 두 가지 위치에서 접근이 이루어지므로 큐의 맨 처음(첫 노드)과 맨 끝(마지막 노드)을 가리키는 참조 타입의 멤버변수가 필요하다. 또한 큐에 저장되어 있는 요소의 개수를 유지하는 변수가 필요하다. 첫 노드와 마지막 노드를 가리키는 참조 타입을 이용하여 저장되어 있는 요소의 개수를 계산할 수 있지만 순차적으로 방문하여 개수를 알아 내야 하므로 비용 측면에서 개수를 유지하는 변수를 사용하는 것이 보다 효과적이다.
- 85 -
LinkedQueue ADT 연결 구조를 이용한 큐의 구현 QueueNode front = null; QueueNode rear = null; int size = 0; 생성자 LinkedQueue() 상태 boolean isFull(); 조건: 항상 거짓 boolean isEmpty(); 조건: front==null
조작 void enq(Object item); QueueOverflowExceptio은 발생하지 않음
Object deq(); isEmpty()가 참이면 QueueUnderflowException 발생
연결구조 방식의 구현이므로 스택과 마찬기지로 isFull 메소드는 항상 false를 반환하도록 구 현한다. isEmpty는 큐에 첫 노드를 가리키는 front을 이용하여 검사할 수도 있고, front을 이 용하지 않고, size를 이용하여 검사할 수도 있다.
enqueue enqueue 알고리즘 단계 1. 새 요소를 위한 노드 생성 단계 2. 이 요소의 info 값에 item 할당 단계 3. 이 요소를 rear에 추가 단계 4. rear가 이 요소를 가리키도록 변경함 public void enq(Object item) { QueueNode newNode = new QueueNode(); newNode.info = item; newNode.link = null; // 큐가 비어 있는 경우 if(rear == null) front = newNode; // 큐에 요소가 있는 경우 else rear.link = newNode; rear = newNode; size++; }
queue
front
size
rear
3 info 10
2
info
2 1. 생성 null
20
enqueue는 큐의 맨 끝에 추가하는 것이므로 보통 연결구조의 맨 끝 노드를 가리키고 있는 rear 정보만 변경하면 된다. 하지만 현재 큐가 비어 있는 경우에는 맨 앞 노드를 가리키고 있는 front 정보도 변경되어야 한다. 위 슬라이드의 코드를 보면 rear를 이용하여 큐가 비어 있는지 검사하고 있다. 이 부분은 size를 이용하여 검사할 수도 있고, isEmpty 메소드를 호 출하여 검사할 수도 있다. 물론 메소드의 호출보다는 멤버 변수를 검사하여 판단하는 것이 효율적이다.
- 86 -
dequeue dequeue 알고리즘 단계 1. queue가 비어있는지 검토 단계 2. front.item을 임시 보관 단계 3. front가 front.link를 가리키도록 변경함 단계 4. 큐에 더 이상 요소가 없으면 rear를 null로 설정함 단계 5. 임시 보관한 item을 반환 queue
front
rear
size
1
info
info
null
10
20
2
tmp
1
public Object deq() throws QueueUnderflowException{ if(isEmpty()) throw new QueueUnderflowException(“…”); Object tmp = front.info; front = front.link; size--; if(isEmpty()) rear = null; return tmp; }
dequeue는 enqueue와 반대로 보통 큐의 맨 앞 노드를 가리키고 있는 front 정보만 변경하 면 된다. 하지만 dequeue 결과 큐가 빈 상태가 되면 맨 끝 노드를 가리키고 있는 rear 정보 도 변경되어야 한다. 이처럼 연결구조를 구현할 때에는 먼저 가능한 모든 경우를 생각해보 고, 각 경우가 동일한 행동을 통해 해결 가능한지 검토해야 한다.
Circular Linked Queue queue
rear
info
info
info
10
20
30
순환 연결 구조를 이용하면 하나의 참조만을 이용하여 큐를 구현할 수 있다. front만 유지하면 dequeue는 O(1)이지만 enqueue는 O(n)이 된다. rear만 유지하면 큐 맨 뒤는 rear로 큐 맨 앞은 rear.link로 바로 접근할 수 있다.
큐는 보통 두 가지 위치에서 접근이 필요하므로 각 위치 정보를 별도의 멤버 변수에 유지한 다. 하지만 연결구조 방식의 구현에서는 마지막 노드가 첫 노드를 가리키도록 하여 하나의 멤버변수만 유지할 수 있다. 이 때 이 멤버 변수는 마지막 노드를 가리키도록 하여야 한다. 이것은 첫 노드만을 유지하는 변수만 사용하면 마지막 노드를 접근하기 어렵기 때문이다. 마지막 노드를 가리키는 변수를 유지할 경우에는 시작 노드와 끝 노드를 모두 효과적으로 접근할 수 있다. 이런 방식의 연결구조를 이용한 큐를 순환 큐라 한다.
- 87 -
Circular Linked Queue: enq public void enq(Object item) { QueueNode newNode = new QueueNode(); newNode.info = item; newNode.link = null; if(rear == null){ // list가 empty인 경우 rear = newNode; rear.link = newNode; } else{ newNode.link = rear.link; rear.link = newNode; rear = newNode; } size++; }
queue
3
rear
info
info
10
20
2
info 30
1
ArrayQueue VS. LinkedQueue ArrayQueue
LinkedQueue
O(1)
O(1)
dequeue
O(1)
O(1)
constructor
O(N)
O(1)
N+1 참조, 3 색인
2N+2 참조(no waste)
enqueue
space
Which one is better? 상황에 따라 다르다. 공간 활용 측면에서 LinkedStack이 우수: 큐에 있는 요소가 배열 용량의 반 이하일 경우
배열을 이용한 큐의 구현과 연결구조를 이용한 큐의 구현의 비교는 스택의 비교와 유사하 다. 스택과 마찬가지로 두 구현 방식에 의한 enqueue와 dequeue는 모두 상수시간 알고리 즘이다. 즉, 두 구현 방식은 공간 활용 측면에서만 다르다.
- 88 -
7.5. 연결구조를 이용한 리스트의 구현 이 절에서는 연결구조를 이용한 비정렬 리스트와 정렬 리스트의 구현을 살펴본다. 또한 반 복자 클래스를 정의하여 사용해본다. 지금까지 반복자는 ADT를 구현하는 클래스 내에 필요 한 메소드를 직접 구현하여 제공하였지만 이 절부터는 별도의 클래스를 통해 반복자를 제공 한다.
List Class Hierarchy List
ArrayList
UnsortedArrayList
import java.util.Iterator; public interface List extends Iterable{ boolean isEmpty(); boolean isFull(); void clear(); int size(); boolean search(Object item); void insert(Object item); boolean delete(Object item); Object retrieve(Object item); Iterator iterator(); } // List
LinkedList
SortedArrayList
use inner class
UnsortedLinkedList
ListNode
SortedLinkedList
리스트와 관련된 전체 클래스의 계층구조는 위 슬라이드와 같다. 여기서 List는 인터페이스 로서 모든 리스트 자료구조가 기본적으로 제공해야 하는 연산들을 선언하고 있다. java,lang 패키지에는 Iterable 인터페이스가 정의되어 있으며, 이 인터페이스를 구현하는 클래스는
Iterator iterator(); 형태의 반복자를 제공해야 한다. Iterator는 java.util 패키지에 정의되어 있 는 인터페이스로서, 반복자 클래스가 기본적으로 제공해야 하는 연산들을 선언하고 있다.
ArrayList와 LinkedList는 각각 배열을 이용한 구현, 연결구조를 이용한 구현을 위한 추상 클 래스이다.
Import java.util.Iterator; public abstract class LinkedList implements List{ protected class ListNode{ public Object info; public ListNode next; } // ListNode protected class ListIterator implements Iterator{ … ListNode와 ListIterator 내부 클래스가 } // ListIterator protected인 이유는 자식 클래스인 protected ListNode list = null; UnsortedLinkedList와 protected int size = 0; SortedLinkedList에서 활용하기 위함이다. public LinkedList(){} public boolean isFull(){ return false; } public boolean isEmpty(){ return (list == null); } void clear(){ … } public int size(){ return size; } public Iterator iterator() { return new ListIterator(list); } public abstract boolean search(Object item); public abstract void insert(Object item); public abstract boolean delete(Object item); public abstract Object retrieve(Object item); } // LinkedList
- 89 -
반복자 클래스는 LinkedList의 내부 클래스로 정의하였다. 이것은 외부로부터 이 클래스를 숨기기 위한 목적보다는 내부 클래스는 그 클래스가 속한 외부 클래스의 멤버를 쉽게 접근 할 수 있기 때문에 이 기능을 활용하기 위함이다. 하지만 반드시 이와 같이 반복자 클래스 를 내부 클래스로 구현할 필요는 없다. 반복자 클래스 ListIterator와 노드를 나타내는
ListNode 클래스를 protected로 선언한 이유는 자식 클래스들인 UnsortedLinkedList와 SortedLinkedList에서 이들에 대한 접근을 용이하게 하기 위함이다.
반복자 클래스 protected class ListIterator implements Iterator{ public ListNode cursor; public int traverseCount = 0; public ListIterator(ListNode node){ cursor = node; } public Object next(){ 반드시 반복자 클래스를 내부 클래스로 Object tmp = cursor.info; 구현할 필요는 없다. cursor = cursor.next; traverseCount++; return tmp; } traverseCount 변수를 사용하지 않고 public boolean hasNext(){ cursor만을 이용하여 구현할 수도 있다. return (traverseCount<size); 하지만 CircularLinkedList까지 고려하여 // return (cursor!=null) traverseCount 변수를 활용하고 있다. } public void remove(){ throw new UnsupportedOperationException(); } } // ListIterator
java.util 패키지에 정의되어 있는 Iterator 인터페이스에는 next, hasNext, remove 세 가지 메 소드가 선언되어 있다. 이 중 remove는 현재 방문 중인 요소를 삭제할 때 사용하는 메소드 이다. 이 메소드를 제공할 필요가 없는 경우에는 UnsupportedOperationException 예외를 발 생하도록 구현하면 된다. LinkedList에서 사용하는 반복자 클래스는 traverseCount라는 멤버 변수를 사용하고 있는데, 이 멤버 변수를 사용하지 않고 hasNext() 메소드에서 cursor!=null 을 이용하여 구현할 수 있다. 하지만 여기서 traverseCount를 사용하는 이유는 다음 장에서 살펴볼 순환 연결 리스트에서도 같은 반복자 클래스를 사용하기 위함이다. 생성자에서
cursor를 list로 초기화하지 않고 node라는 매개변수를 받아 cursor를 초기화하는 이유도 같 은 이유이다.
- 90 -
UnsortedLinkedList, SortedLinkedList public class UnsortedLinkedList extends LinkedList{ public UnsortedLinkedList() { super(); } public boolean search(Object item) throws ListUnderflowException{ … } public void insert(Object item){ … } public boolean delete(Object item) throws ListUnderflowException{ … } public Object retrieve(Object item) throws ListUnderflowException{ …} } // UnsortedLinkedList public class SortedLinkedList extends LinkedList{ public SortedLinkedList() { super(); } public boolean search(Object item) throws ListUnderflowException{ … } public void insert(Object item){ … } public boolean delete(Object item) throws ListUnderflowException{ … } public Object retrieve(Object item) throws ListUnderflowException{ …} } // UnsortedLinkedList
이진검색을 할 수 없다. 없다. 정렬되어 있을 경우에는 검색을 중간에 중단할 수 있다.
연결구조 방식의 정렬 리스트와 비정렬 리스트 클래스는 위 슬라이드와 같다. 배열을 이용 한 구현과 달리 연결구조 방식의 정렬 리스트는 임의 접근을 제공하지 않으므로 이진 검색 을 할 수 없다.
UnsortedLinkedList: search public boolean search(Object item) throws ListUnderflowException{ if(isEmpty()) throw new ListUnderflowException(“…”); if(item==null) throw new NullPointerException(“…”); // if(item==null) return false; ListNode loc = list; boolean moreToSearch = true; do{ if(item.equals(loc.info)) return true; else{ loc = loc.next; moreToSearch = (loc != null); } }while(moreToSearch); return false; } // UnsortedList: search loc info
info
info
info
10
50
30
20
연결구조 방식이므로 정렬 리스트이어도 순차적으로 검색할 수밖에 없다. item이 null인지 여부를 검사하여 nullPointerException을 직접 발생할 수 있고, 주석과 같이 false 반환할 수 도 있다. 전자는 문제의 원인을 정확하게 전달하는 측면에서 이점이 있고, 후자는 예외 발 생없이 프로그램이 계속 수행된다는 측면에서 이점이 있다. 검색에서 비교는 보통 equals 메소드를 사용하여 구현한다. 종료조건을 나타내는 moreToSearch라는 변수를 활용할 수 있 고, 이것을 사용하지 않고 다음 슬라이드처럼 size 정보를 이용할 수도 있다.
- 91 -
UnsortedLinkedList: search
public boolean search(Object item) throws ListUnderflowException{ if(isEmpty()) throw new ListUnderflowException(“…”); if(item==null) throw new NullPointerException(“…”); ListNode loc = list; for(int i=0; i<size; i++){ if(item.equals(loc.info)) return true; else loc = loc.next; } // for return false; } // UnsortedList: search
SortedLinkedList: search public boolean search(Object item) throws ListUnderflowException{ if(isEmpty()) throw new ListUnderflowException(“…”); if(item==null) throw new NullPointerException(“…”); Comparable x = (Comparable)item; ListNode loc = list; boolean moreToSearch = true; do{ int compResult = x.compareTo(loc.info); if(compResult==0) return true; else if(compResult<0) return false; else{ loc = loc.next; moreToSearch = (loc != null); } }while(moreToSearch); return false; } // SortedLinkedList: search (중간에 중단 가능) list info
info
info
info
10
20
30
50
배열을 이용한 정렬 리스트의 구현에서는 이진 검색을 사용하여 로그 시간에 검색을 할 수 있다. 그러나 연결구조 방식의 정렬 리스트에서는 임의 접근을 제공하지 않으므로 정렬 리 스트에서도 선형 검색밖에는 할 수 없다. 하지만 비정렬 리스트와 달리 정렬되어 있으므로 검색을 중간에서 중단할 수도 있다. 중간에 중단하기 위해서는 equals 메소드 대신에
compareTo 메소드를 사용해야 한다. 또한 이를 위해 인자로 받은 item을 Comparable 타입 으로 타입변환해야 한다.
- 92 -
SortedLinkedList: search public boolean search(Object item) throws ListUnderflowException{ if(item==null) throw new NullPointerException(“…”); if(isEmpty()) throw new ListUnderflowException(“…”); Comparable x = (Comparable)item; ListNode loc = list; for(int i=0; i<size; i++){ int compResult = x.compareTo(loc.info); if(compResult==0) return true; else if(compResult<0) return false; else loc = loc.next; } // for return false; } // SortedLinkedList: search (중간에 중단 가능)
public boolean delete(Object item) throws ListUnderflowException{ if(isEmpty()) throw new ListUnderflowException(“…”); if(item==null) throw new NullPointerException(“…”); ListNode loc = list; ListNode prevLoc = null; for(int i=0; i<size; i++){ if(x.equals(loc.info)){ // 삭제할 요소가 있는 경우 if(prevLoc==null) list = list.next; // 리스트의 처음 else prevLoc.next = loc.next; size--; return true; } else{ // 검색을 계속해야 하는 경우 prevLoc = loc; loc = loc.next; } } // for return false; } // UnsortedLinkedList: delete list
prevLoc
loc
info
info
info
info
10
50
30
10
연결구조 방식의 비정렬 리스트에서 중간 노드를 삭제하기 위해서는 그 노드의 선행 노드와 후속 노드 정보를 모두 알아야 한다. 즉, 그 노드의 선행 노드의 연결 정보를 노드의 후속 노드로 변경해주어야 한다. 이를 위해 삭제하고자 하는 요소를 찾을 때 현재 노드 정보(loc) 와 현재 노드의 선행 노드 정보(prevLoc)를 함께 유지해야 한다. 또 리스트을 조작할 때에는 크게 네 가지 경우로 나누어 고려해야 한다. 첫째, 리스트가 비어 있는 경우 둘째, 리스트 맨 앞에 요소를 추가/맨 앞에 있는 요소를 삭제하는 경우 셋째, 리스트 중간에 요소를 추가/중간에 있는 요소를 삭제하는 경우 넷째, 리스트 맨 끝에 요소를 추가/맨 끝에 있는 요소를 삭제하는 경우 삭제의 경우에는 리스트가 비어 있는 경우에는 ListUnderflowException을 발생한다. 맨 앞에 있는 요소를 삭제하는 경우에는 리스트의 첫 노드를 가리키는 list 정보도 변경해야 한다. 중 간 또는 끝에 있는 요소를 삭제하는 경우는 모두 현재 노드의 선행 노드의 연결을 현재 노 드의 후속 노드를 가리키도록 변경해주면 된다. 즉, 중간 또는 끝에 있는 요소의 삭제는 동
- 93 -
일하게 취급할 수 있다.
SortedLinkedList: delete public boolean delete(Object item) throws ListUnderflowException{ if(item==null) throw new NullPointerException(“…”); if(isEmpty()) throw new ListUnderflowException(“…”); Comparable x = (Comparable)item; ListNode loc = list; ListNode prevLoc = null; for(int i=0; i<size; i++){ int compResult = x.compareTo(loc.info); if(compResult==0){ // 삭제할 요소가 있는 경우 if(prevLoc==null) list = list.next; // 리스트의 처음 else prevLoc.next = loc.next; size--; return true; } // 삭제할 요소가 없는 경우 else if(compResult<0) return false; else{ // 검색을 계속해야 하는 경우 prevLoc = loc; loc = loc.next; } } // for return false; } // SortedLinkedList: delete
정렬 리스트에서 삭제는 삭제하고자 하는 요소를 찾을 때 리스트 중간에서 검색을 중단할 수 있다는 것을 제외하고는 비정렬 리스트와 같다.
UnsortedLinkedList: insert 배열 구현의 경우에는 맨 끝에 연결 구조 구현의 경우에는 맨 처음에
2
public void insert(Object item){ ListNode newNode = new ListNode(); newNode.info = item; newNode.next = list; list = newNode; size++; } // UnsortedLinkedList: insert
list
info 25
1 info
info
info
info
10
50
30
10
배열을 이용한 비정렬 리스트의 구현에서 삽입은 항상 리스트의 끝에 이루어졌다. 하지만 연결구조를 이용한 비정렬 리스트의 구현에서는 삽입은 항상 리스트 맨 앞에 하는 것이 가 장 효율적이다.
- 94 -
public void insert(Object item){ if(item==null) throw new NullPointerException(“…”); Comparable x = (Comparable)item; ListNode newNode = new ListNode(); newNode.info = item; ListNode loc = list; ListNode prevLoc = null; for(int i=0; i<size; i++){ // 삽입할 위치 결정 if(x.compareTo(loc.info)<0) break; else{ prevLoc = loc; loc = loc.next; } } // for if(prevLoc==null){ // 리스트의 맨 처음 newNode.next = list; list = newNode; 2 } else{ // 리스트 중간 또는 끝 newNode.next = loc; info prevLoc.next = newNode; } 20 size++; } // SortedLinkedList: insert
list
1 info 10
info 25
1 info 30
정렬 리스트의 경우에는 삽입할 위치를 찾아야 하며, 삭제 연산을 설명할 때와 마찬가지로 크게 네 가지 경우를 고려하여 구현해야 한다. 삽입의 경우에는 리스트가 비어 있는 경우에 삽입하는 경우와 리스트 맨 앞에 삽입하는 경우를 동일하게 취급할 수 있다. 또한 리스트 중간에 삽입하는 경우와 리스트 맨 끝에 삽입하는 경우를 동일하게 취급할 수 있다.
반복자의 사용 void print(SortedLinkedList l){ Iterator i = l.iterator(); while(i.hasNext()){ System.out.print(i.next()+”,”); } System.out.println(); }
내부 클래스로 구현된 반복자 클래스를 사용하는 방법은 위 슬라이드와 같다. 즉, iterator 메소드를 호출하여 반복자를 생성한 다음에 hasNext와 next 메소드를 이용하여 구조에 저 장되어 있는 요소들을 차례로 방문할 수 있다.
- 95 -
List의 비교 Unsorted, Array
Unsorted, Linked
Sorted, Array
Sorted, Linked
search
O(N)
O(N)
O(logN)
O(N)
retrieve
O(N)
O(N)
O(logN)
O(N) O(N), O(1) 전체: O(N)
insert
O(1)
O(1)
O(logN), O(N) 전체: O(N)
delete
O(N), O(1) 전체: O(N)
O(N), O(1) 전체: O(N)
O(logN), O(N) 전체: O(N)
O(N), O(1) 전체: O(N)
space
N+1 참조, 2 정수
2N+2 참조, 1 정수
N+1 참조, 2 정수
2N+2 참조, 1 정수
지금까지 살펴본 네 가지 리스트의 성능을 비교하여 보자. 우선 구현방식과 상관없이 비정 렬 리스트는 선형 검색만 가능하므로 검색, 삭제는 모두 최소 선형 시간 알고리즘이다. 정 렬 리스트의 경우에는 배열을 이용한 구현에서는 이진 검색을 할 수 있으므로 로그 시간 알 고리즘이지만 연결구조를 이용한 구현에서는 여전히 선형 시간 알고리즘(최악의 경우)이다. 삽입의 경우에는 비정렬 리스트의 경우에는 구현방식과 상관없이 상수 시간에 가능하지만 정렬 리스트의 경우에는 삽입 위치를 찾아야 하므로 배열을 이용한 구현은 최소 로그 시간 알고리즘이고, 연결구조를 이용한 구현은 최소 선형 시간 알고리즘이다. 하지만 배열을 이 용한 구현에서는 후속 요소들을 모두 하나씩 이동해야 하므로 결국 최소 선형 시간 알고리 즘이다. 반대로 연결구조를 이용한 구현에서는 실제 삽입은 연결만 바꾸면 된다.
- 96 -
제8장 연결구조 확장 이 장에서는 연결구조를 이용한 리스트 구현의 여러 가지 확장을 살펴본다.
8.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 순환 연결 리스트 이중 연결 리스트 배열을 이용한 연결구조 방식의 리스트 구현
8.2. 순환 연결 리스트
순환 연결 리스트 연결 리스트에서 마지막 노드의 링크를 첫 노드를 가리키도록 변형한 리스트 list
info
info
info
10
20
30
장점. 장점. 어떤 노드에서 출발해도 전체 리스트를 방문할 수 있다. 기존 연산들은 항상 마지막 노드가 첫 노드를 가리키도록 수정해야 한다. 수정시 가장 큰 문제점 첫 노드의 삭제 또는 리스트 맨 앞에 노드의 삽입: 마지막 노드까지 이동하여야 한다.
순환 연결 리스트는 7장에서 살펴본 순환 연결 큐와 유사하다. 보통 연결구조 방식의 리스 트에서는 첫 번째 노드를 가리키는 정보만 유지하고 마지막 노드의 연결 정보는 null 값을 가지게 된다. 이것을 변형하여 마지막 노드가 첫 노드를 가리키도록 하면 어떤 노드에서 출 발하여도 전체 리스트를 방문할 수 있는 이점을 얻을 수 있다. 하지만 이전처럼 첫 번째 노
- 97 -
드를 가리키는 정보만 유지할 경우에는 첫 노드로 새 요소가 삽입되거나 첫 노드를 삭제해 야 하는 경우에 비용이 너무 많이 소요된다.
순환 연결 리스트 – 계속 해결책: 해결책 첫 노드 대신에 마지막 노드를 가리키는 포인터 유지 info
info
info
10
20
30
list
첫 노드와 마지막 노드를 접근하기가 모두 용이함 마지막 노드의 내용: list.info 첫 노드의 내용: list.next.info info
list
30
이것을 해결하여 위해 첫 노드에 대한 정보를 유지하지 않고 마지막 노드에 대한 정보를 유 지할 수 있다. 이렇게 하면 첫 노드와 마지막 노드에 대한 접근이 모두 용이해진다. 따라서 첫 노드로 새 요소를 삽입하는 비용이나 첫 노드를 삭제하는 비용이 모두 상수 시간이다. 뿐만 아니라 정렬 리스트를 순환 연결 리스트로 구현하면 가장 큰 값과 가장 작은 값을 상 수 시간에 접근할 수 있으므로 이를 이용하여 각 종 연산들을 매우 효율적으로 개선할 수 있다.
List Class Hierarchy List
ArrayList
UnsortedArrayList
LinkedList
SortedArrayList
UnsortedLinkedList
SortedLinkedList
CircularSortedLinkedList
이 절에서는 순환 정렬 연결 리스트를 구현하는 방법을 살펴본다. 순환 정렬 연결 리스트를 구현하기에 앞서 기존 리스트 관련 클래스 계층 구조에서 어디에 순환 연결 리스트를 위치 해야 하는지 결정해야 한다. 순환 연결 리스트도 기존 연결구조 방식의 리스트와 마찬가지 로 ListNode라는 내부 클래스를 사용해야 하며, isFull, isEmpty, size와 같은 메소드들은
LinkedList 추상 클래스에 정의되어 있는 것을 그대로 사용할 수 있다. 하지만 삽입, 삭제,
- 98 -
검색 메소드는 기존 일반 정렬 연결 리스트의 정의되어 있는 메소드를 그대로 사용할 수 없 다. 이것은 기존 정렬 연결 리스트는 list라는 멤버 변수가 첫 노드를 가리킨 반면 순환 연결 방식에서는 마지막 노드를 가리키고 있기 때문이다. 따라서 순환 정렬 연결 리스트는
SortedLinkedList를 상속받는 것보다는 LinkedList를 상속받는 것이 올바르다.
CircularSortedLinkedList public class CircularSortedLinkedList extends LinkedList{ public CircularSortedLinkedList() { super(); } public boolean search(Object item) throws ListUnderflowException{ … } public void insert(Object item){ … } public boolean delete(Object item) throws ListUnderflowException{ … } public Object retrieve(Object item) throws ListUnderflowException{ … } public Iterator iterator() { return (list==null) ? new ListIterator(null) : new ListIterator(list.next); } }
CircularSortedLinkedList의 search public boolean search(Object item) throws ListUnderflowException{ if(isEmpty()) throw new ListUnderflowException(“…”); Comparable x = (Comparable)item; ListNode loc = list.next; for(int i=0; i<size; i++){ int compResult = x.compareTo(loc.info); if(compResult==0) return true; else if(compResult<0) return false; else loc = loc.next; } return false; } // CircularSortedList::search loc
list
info
info
info
info
10
20
30
40
순환 정렬 연결 리스트에서 검색은 기존 정렬 연결 리스트에서 검색과 loc의 초기화만 다를 뿐 같다. 삭제와 삽입 연산은 loc의 초기화뿐만 아니라 첫 노드와 마지막 노드가 조작되면 순환을 유지하기 위한 추가 작업이 필요하다.
- 99 -
CircularSortedLinkedList의 delete prevLoc
loc
list
info
info
info
info
10
20
30
40
prevLoc
loc
list
info
info
info
info
10
20
30
40
일반적인 경우 prevLoc.next = loc.next;
첫 노드 list.next = list.next.next; 또는 list.next = loc.next;
CircularSortedLinkedList의 delete loc prevLoc
list
info
info
info
info
10
20
30
40 마지막 노드 prevLoc.next = loc.next; list = prevLoc;
loc prevLoc
list
info
유일 노드 list = null;
10
항상 연결구조 방식의 구현에서는 크게 네 가지 경우를 고려해야 한다. 첫 노드의 삭제, 첫 노드의 삭제의 특수한 경우로 리스트가 노드에 하나밖에 없는 경우, 중간 노드의 삭제, 마 지막 노드의 삭제를 고려해야 한다. 중간 노드의 삭제는 기존 정렬 연결 리스트와 동일하지 만 첫 노드의 삭제 또는 마지막 노드의 삭제에 대해서는 순환을 유지하기 위한 추가 작업이 필요하다.
- 100 -
public boolean delete(Object item) throws ListUnderflowException{ if(isEmpty()) throw new ListUnderflowException(“…”); Comparable x = (Comparable)item; ListNode loc = list.next; ListNode prevLoc = null; for(int i=0;i<size;i++){ int compResult = x.compareTo(loc.info); if(compResult==0){ // 삭제할 노드가 존재하는 경우 if(prevLoc==null){ // 첫 노드를 삭제하는 경우 if(loc == loc.next) list = null; // 첫 노드가 유일 노드인 경우 else list.next = loc.next; if(loc == loc.next) 대신에 } if(size==1)를 사용할 수 있음 else{ prevLoc.next = loc.next; if(loc==list) list = prevLoc; // 마지막 노드를 삭제하는 경우 } size--; return true; } else if(compResult<0) return false; else{ prevLoc = loc; loc = loc.next; } } // for return false; } // CircularSortedList::delete
첫 노드와 마지막 노드를 모두 접근하기가 용이하다는 순환 연결 구조의 특성을 이용하여 보다 효율적으로 delete 메소드를 구현할 수 있다. 즉, 첫 노드와 마지막 노드와 비교하여 첫 노드보다 작은 경우와 첫 노드와 큰 경우에 대해서는 리스트 전체를 검색하여 삭제할 노 드가 있는지 찾는 과정을 생략할 수 있다.
첫 노드와 마지막 노드를 모두 접근하기가 용이하다는 순환 연결 구조의 특성을 이용한 delete 메소드
public boolean delete(Object item) throws ListUnderflowException{ if(isEmpty()) throw new ListUnderflowException(“…”); Comparable x = (Comparable)item; if(x.compareTo(list.next.info)<0||x.compareTo(list.info)>0){ return false; } // 나머지 부분은 동일 } // CircularSortedList::delete
- 101 -
CircularSortedLinkedList의 insert loc prevLoc
list
info
info
info
10
20
40
빈 리스트 list = newNode; list.next = newNode; loc prevLoc
list
info 일반적인 경우 newNode.next = loc; prevLoc.next = newNode;
info
30
10
CircularSortedLinkedList의 insert prevLoc
loc
list
info
info
info
info
10
20
30
40
첫 노드 newNode.next = loc; list.next = newNode;
prevLoc loc
list
info
info
info
info
10
20
30
40
마지막 노드 newNode.next = loc; prevLoc.next = newNode; list = newNode;
삽입도 삭제와 마찬가지로 크게 네 가지 경우를 고려해야 한다. 리스트 맨 앞에 추가하는 경우, 맨 앞에 추가의 특수한 경우로 리스트가 비어 있는 경우, 중간에 삽입하는 경우, 리스 트 맨 끝에 삽입하는 경우를 고려해야 한다. 각 경우에 유사점이 있는 경우에는 이를 활용 하여 구현하는 것이 바람직하다. 예를 들어 중간에 삽입하는 경우와 맨 끝에 삽입하는 경우 모두 동일하게 newNode,next = loc; prevLoc.next = newNode;는 실행되어야 한다.
- 102 -
public void insert(Object item){ Comparable x = (Comparable)item; ListNode newNode = new ListNode(); newNode.info = item; newNode.next = null; ListNode loc = (list==null)? null: list.next; ListNode prevLoc = null; for(int i=0;i<size;i++){ // 삽입할 위치 찾기 if(x.compareTo(loc.info)<0) break; else{ prevLoc = loc; loc = loc.next; } } // for if(prevLoc==null){ // 리스트 맨 앞에 삽입되는 경우 // 리스트가 비어있는 경우 if(list==null){ list = newNode; newNode.next = newNode; } else{ newNode.next = loc; list.next = newNode; } } else{ newNode.next = loc; prevLoc.next = newNode; // 리스트의 맨 마지막에 삽입되는 경우 if(prevLoc == list) list = newNode; } size++; } // CircularSortedList::insert
위 슬라이드에 있는 삽입 연산은 기존 정렬 연결 리스트와 동일한 방법으로 구현한 것이다. 먼저 삽입할 위치를 찾은 다음, 삽입할 위치에 따라 필요한 각 종 연결을 변경하는 방식이 다. 다음 슬라이드에 있는 삽입 연산은 순환 연결 리스트의 특성을 활용하고 있다. 즉, 새 요소를 추가할 때 그 요소를 리스트의 첫 요소와 마지막 요소와 비교하여 첫 요소보다 작거 나 마지막 요소보다 클 경우에는 이것을 먼저 처리하여 준다. 두 경우는 정렬된 순환 연결 구조의 특성 상 같은 위치에 삽입되는 형태가 된다. 첫 노드와 마지막 노드를 public void insert(Object item){ 모두 접근하기가 용이하다는 Comparable x = (Comparable)item; 순환 연결 구조의 특성을 ListNode newNode = new ListNode(); 이용한 insert 메소드 newNode.info = item; newNode.next = null; size++; if(list==null){ list = newNode; newNode.next = newNode; return; } // 리스트가 비어 있는 경우 if(x.compareTo(list.next.info)<0||x.compareTo(list.info)>0){ newNode.next = list.next; list.next = newNode; if(x.compareTo(list.info)>0) list = newNode; // 맨 끝에 삽입되는 경우 return; } // 삽입하고자 하는 노드가 첫 노드보다 작거나 마지막 노드보다 큰 경우 // 첫 노드와 비교할 필요 없음 ListNode loc = list.next.next; ListNode prevLoc = list.next; for(int i=0;i<size;i++){ if(x.compareTo(loc.info)<0){ newNode.next = loc; prevLoc.next = newNode; return; } else{ prevLoc = loc; loc = loc.next; } } // for } // CircularSortedList::insert
- 103 -
CircularSortedLinkedList의 장단점 연산 측면에서는 SortedLinkedList에 비해 향상된 것이 없다. 그러나 CircularSortedLinkedList는 일반 정렬 연결리스트와 달리 첫 노드와 마지막 노드에 대한 접근이 용이하다. 따라서 다음과 같은 것은 일반 정렬 연결리스트보다 우수하다. 가장 큰 값의 추출 리스트에 저장된 값들 사이에 있는 값인지 비교하는 것 정렬된 데이터를 연속해서 삽입하는 경우 일반 정렬 연결리스트에 마지막 노드를 가리키는 포인터를 하나 더 유지하는 것은? good alternative
순환 정렬 연결 리스트의 장점을 기존 정렬 연결 리스트와 비교하여 보자. 연산의 성능 측 면에서 보면 향상된 것이 거의 없다. 그러나 순환 정렬 연결 리스트는 첫 노드뿐만 아니라 마지막 노드에 대한 접근이 용이하므로 다음과 같은 서비스는 기존 정렬 연결 리스트보다 우수하다. 첫째, 가장 큰 값의 추출 둘째, 리스트에 저장된 값들 사이에 있는 값인지 알 필요가 있는 경우 셋째, 정렬된 데이터를 연속해서 삽입하는 경우 마지막 서비스에 경우에는 삽입 연산이 순환 연결 리스트의 특성을 활용하고 있어야 한다. 이런 측면에서 보았을 때 일반 정렬 연결 리스트에서 마지막 노드를 가리키는 포인터를 하 나 더 유지하는 방법을 생각해 볼 수 있다. 오히려 이 방법이 순환 연결 리스트에 비해 우 수할 수 있다.
- 104 -
8.3. 이중 연결 리스트
이중 연결 리스트 이중 연결 리스트(doubly linked list)란 첫 노드와 마지막 리스트 노드를 제외하고는 모두 후속 요소뿐만 아니라 선행 요소를 가리키는 포인터를 추가로 가지고 있는 리스트 list
info
info
info
10
20
30
어떤 노드에서 출발해도 전체 리스트를 방문할 수 있다. 역순으로 노드들을 방문할 수 있다. 있다. 노드의 구성: back, info, next protected class DLListNode{ public Object info; 3N+1 public DLListNode back; public DLListNode next; }
이전까지의 연결구조 방식의 자료구조의 각 노드는 한 쪽 방향의 연결만을 유지하고 있었 다. 이중 연결 리스트는 이와 달리 양쪽 방향의 연결을 한 노드에 모두 유지한다. 따라서 한 노드에 3개의 참조를 유지해야 하므로 노드 당 차지하는 공간이 많다. 이중 연결 리스트 는 현재 위치한 노드로부터 이전 연결구조 방식의 자료구조처럼 후속 요소들을 접근할 수 있을 뿐만 아니라 선행 요소들도 접근할 수 있다. 이 기능 때문에 기존 연결구조 방식의 자 료구조와 달리 역순으로 노드들을 방문할 수 있다는 장점을 지니고 있다.
List Class Hierarchy List
ArrayList
UnsortedArrayList
LinkedList
SortedArrayList
UnsortedLinkedList
SortedLinkedList
DoubleLinkedList
UnsortedDoubleLinkedList
SortedDoubleLinkedList
이중 연결 리스트는 노드 자체가 일반 연결 리스트와 다르므로 LinkedList 클래스와 상속 관 계를 가질 수 없다. 따라서 DoubleLinkedList라는 추상 클래스를 만들어 List 인터페이스를 구현하도록 하고, 이중 정렬 연결 리스트와 이중 비정렬 연결 리스트는 DoubleLinkedList를 상속받아 구현하도록 한다.
- 105 -
public abstract class DoubleLinkedList implements List{ protected class ListNode{ public Object info; public ListNode back; public ListNode next; } protected class ListIterator{ … } protected ListNode list = null; protected int size = 0; public DoubleLinkedList() {} public abstract boolean search(Object item) throws ListUnderflowException; public abstract void insert(Object item); public abstract boolean delete(Object item) throws ListUnderflowException; public abstract Object retrieve(Object item) throws ListUnderflowException; public boolean isFull(){ return false; } public boolean isEmpty(){ return (list == null); } public void clear{ list = null; } public int size(){ return size; } public Iterator iterator() { return new ListIterator(list); } } // DoubleLinkedList class
DoubleLinkedList에서 노드를 나타내는 클래스의 이름은 ListNode이고, 반복자 클래스의 이 름은 ListIterator이다. 즉, LinkedList가 사용하는 이름과 동일한 이름을 사용한다. 하지만 내 부 클래스의 이름이므로 이렇게 같은 이름을 사용하여도 문제가 되지 않는다. protected class ListIterator{ public ListNode cursor; public int traverseCount = 0; public ListIterator(ListNode node){ cursor = node; } // ListIterator(ListNode) public boolean hasBack(){ return (traverseCount>0); } public boolean hasNext(){ return (traverseCount<size); } public Object back(){ Object tmp = cursor.info; cursor = cursor.back; traverseCount--; return tmp; } // back public Object next(){ Object tmp = cursor.info; cursor = cursor.next; traverseCount++; return tmp; } // next public void remove(){ throw new UnsupportedOperationException(); } // remove }
이중 연결 리스트에서 반복자 클래스에는 기존 연결 리스트에는 없는 back과 hasBack 메소 드가 추가로 구현되어야 한다. 즉, 주어진 노드로부터 그것의 선행 노드를 방문할 수 있는 메소드를 제공해주어야 한다.
- 106 -
SortedDoubleLinkedList의 Insert prevLoc
list
2
3
1
loc
prevLoc
loc
info
info
info
10
20
3
4
30
1
1 2
2
info
info
info
5
25
50
newNode.next = loc; loc.back = newNode; list = newNode;
newNode.back = prevLoc; newNode.next = loc; prevLoc.next = newNode; loc.back = newNode;
newNode.back = prevLoc; prevLoc.next = newNode;
이중 정렬 연결 리스트에서 삽입 연산은 일반 정렬 연결 리스트의 삽입과 마찬가지로 크게 네 가지 경우를 고려해야 한다. 위 슬라이드에서 이 중 리스트가 빈 경우에 삽입하는 경우 를 제외한 나머지 세 가지 경우에 대해 연결을 변경하는 방법을 설명하고 있다. public void insert(Object item){ Comparable x = (Comparable)item; ListNode newNode = new ListNode(); newNode.info = item; newNode.back = null; newNode.next = null; ListNode loc = list; ListNode prevLoc = null; for(int i=0;i<size;i++){ // 삽입할 위치를 찾는다. if(x.compareTo(loc.info)<0) break; else{ prevLoc = loc; loc = loc.next; } } // for if(prevLoc==null){ // 리스트 맨 앞에 삽입되는 경우 list = newNode; newNode.next = loc; if(list!=null) loc.back = newNode; // 빈 리스트가 아닌 경우 } else{ newNode.back = prevLoc; newNode.next = loc; prevLoc.next = newNode; if(loc!=null) loc.back = newNode; // 맨 마지막에 삽입되는 경우가 아니면 } size++; } // SortedDoubleLinkedList::insert
if(prevLoc==null)이 참이면 새 요소를 리스트의 첫 노드로 삽입하는 경우이다. 이 경우는 다 시 리스트가 빈 경우와 그렇지 않은 경우로 나누어진다. 반대로 조건이 거짓이면 새 요소를 리스트 중간 또는 끝에 삽입하는 경우이다. 리스트 중간에 삽입되는 경우에는 새 요소가 선 행 및 후속 요소를 모두 가지게 되지만 리스트 마지막에 삽입되는 경우에는 선행요소만 가 지게 된다.
- 107 -
SortedDoubleLinkedList의 delete prevLoc
loc
1 info
list
info
info
2 10
prevLoc
20
30
info
info
info
10
20
30
일반적인 경우 prevLoc.next = loc.next; loc.next.back = prevLoc;
loc
1 list
리스트의 첫 노드를 삭제하는 경우 loc.next.back = null; list = loc.next;
SortedDoubleLinkedList의 delete
list
prevLoc
list
prevLoc
loc
info
info
info
10
20
30
리스트의 마지막 노드를 삭제하는 경우 prevLoc.next = null;
loc
info
리스트에 있는 유일 노드를 삭제하는 경우 list = null;
10
이중 연결 리스트에서 삭제도 크게 네 가지 경우를 고려해야 한다. 리스트에 존재하는 유일 요소를 삭제하는 경우와 리스트의 맨 앞 요소를 삭제하는 경우를 같이 고려할 수 있다. 이 때 유일한 차이점은 하나는 list가 첫 요소의 후속 요소를 가리키도록 변경되어야 하지만 다 른 하나는 list 값이 null이 되어야 한다. 리스트 중간에 있는 요소를 삭제하는 경우와 리스 트 끝에 있는 요소를 삭제하는 경우도 같이 고려할 수 있다. 두 경우의 유일한 차이점은 리 스 끝에 있는 요소는 후속 노드가 없으므로 그것의 선행 노드 정보만 변경하면 된다.
- 108 -
public boolean delete(Object item) throws ListUnderflowException{ if(isEmpty()) throw new ListUnderflowException("…"); Comparable x = (Comparable)item; ListNode loc = list; List prevLoc = null; for(int i=0;i<size;i++){ int compResult = x.compareTo(loc.info); if(compResult==0){ // 삭제할 노드가 존재하는 경우 if(prevLoc==null){ // 첫 노드를 삭제하는 경우 if(loc.next==null) list = null; // 삭제할 노드가 유일 노드인 경우 else{ loc.next.back = null; list = loc.next; } } else{ prevLoc.next = loc.next; if(loc.next != null) loc.next.back = prevLoc; // 마지막 노드가 아닌 경우 } return true; } else if(compResult<0) return false; else{ prevLoc = loc; loc = loc.next; } } // for return false; }
8.4. 배열을 이용한 연결 리스트의 구현
배열을 이용한 연결구조의 구현
list
색인
info
next
0
David
2
1
null
5
2
John
4
3
Peter
6
4
Mary
3
5
null
END
6
Robert
END
0
free
1
class ArrayLinkedList{ public static final int DEF_MAX_CAPACITY = 50; public static final int END = -1; private class ListNode{ public Object info; public int next; } private int list = END; private ListNode[] nodes; private int size = 0; private int free = 0; … }
size
5
배열을 이용한 연결구조에서 연결은 배열의 색인정보가 된다. 따라서 null 대신에 -1을 이용하여 리스트의 끝을 나타낸다.
이 슬라이드에서 보여 주고 있듯이 배열을 이용하여 연결 리스트를 구현할 수도 있다. 일반 적으로 연결구조 방식에서는 새 요소가 추가될 때마다 동적으로 메모리 공간을 확보한다. 하지만 배열을 이용한 연결 리스트에서는 미리 필요한 만큼의 충분한 메모리 공간을 확보한 다음에 이 공간을 사용자가 시스템을 대신하여 직접 관리한다. 배열을 이용한 연결 리스트 에서 배열의 각 항은 연결구조의 노드를 나타낸다. 따라서 크게 두 가지 차이점이 있다. 첫 째, 배열을 이용한 연결 리스트에서 삽입 연산은 더 이상 사용할 항이 없어 처리할 수 없는 경우가 있다. 둘째, 각 노드의 연결 정보는 기존과 달리 주소 정보가 아니라 배열의 색인 정보가 된다. 그러므로 첫 노드를 가리키는 정보 역시 배열의 색인 정보로 나타낸다. 또한 일반적인 연결구조에서 마지막 노드임을 나타내기 위해 그 노드의 연결 값을 null 값으로 사용한다. 배열을 이용한 연결 리스트의 구현에서는 null 값 대신에 -1이라는 값을 사용한다. 이것은 -1은 유효한 색인 정보가 될 수 없는 값이기 때문이다.
- 109 -
배열을 이용한 연결구조의 구현 – 계속 info
info
info
info
info
David
John
Mary
Peter
Robert
free
null
null
list
info
info
info
info
David
John
Peter
Robert
null
null
null
list
free
Mary의 삭제
배열을 이용한 연결구조의 구현은 내부적으로 두 개의 리스트를 유지한다. 즉, 프로그래머가 스스로 빈 공간을 관리해야 한다.
보다 자세하게 구현 원리를 살펴보면 배열을 이용한 연결구조는 내부적으로 두 개의 리스트 를 유지한다. 하나는 요소들이 저장되어 있는 유효한 노드들을 연결해 놓은 리스트이고, 다 른 하나는 요소들이 저장되어 있지 않은 빈 노드를 연결해 놓은 리스트이다. 만약 새 요소 를 추가해야 하면 빈 노드들의 리스트에서 하나의 노드를 선택하여 이 노드에 새 요소를 대 입하고 이 노드를 유효한 노드들의 리스트에 포함한다. 반대로 기존 요소를 삭제해야 하면 그 노드를 유효한 노드들의 리스트에서 제거하고 빈 노드들의 리스트로 옮긴다.
배열을 이용한 연결구조의 구현 – 계속 배열을 이용한 연결구조 구현의 장점 매번 삽입할 때마다 공간 할당이 이루어지지 않는다. 동적 메모리 할당을 제공하지 않는 프로그래밍 환경에서는 이 방법이 유일한 대안이다. 배열을 이용한 연결구조 구현의 단점 일반적인 연결구조 방식과 달리 삽입 연산의 경우 리스트가 꽉 찬 경우를 고려해야 한다. 0 2
David
1
2
5
4
John
3
4 6
Peter
5 3
Mary
6 -1
-1
Robert
배열을 이용한 연결 리스트는 일반 연결 리스트와 달리 매번 삽입할 때마다 공간 할당이 이 루어지지 않는다는 장점을 지니고 있다. 하지만 배열의 본질적인 특성 때문에 삽입 연산에 서 공간이 부족한 경우를 고려해야 한다.
- 110 -
ArrayLinkedList의 생성자 public ArrayLinkedList(int capacity){ if(capacity<0) setup(DEF_LIST_CAPACITY); else setup(capacity); } // ArrayLinkedList(int) public void setup(int capacity){ nodes = new ListNode[capacity]; // 빈 노드들의 리스트 생성 for(int i=0;i<capacity;i++){ nodes[i] = new ListNode(); nodes[i].info = null; 미리 모든 노드를 만든다. nodes[i].next = i+1; } // for 새로운 item을 삽입할 때에는 nodes[capacity-1].next = END; 새 노드를 만들지 않고 배열에 } // ArrayLinkedList::setup(int) 만들어 놓은 노드에 item을 삽입한다.
배열을 이용한 연결 리스트는 리스트를 처음 생성할 때 모든 노드를 미리 만든다. 또한 앞 서 언급한 바와 같이 내부적으로 두 개의 리스트를 유지해야 하므로, 처음 생성할 때에는 배열의 모든 항들을 연결하여 빈 노드들의 리스트를 만들어야 한다.
SortedArrayLinkedList의 insert 색인
info
next
색인
info
next
0
null
1
0
john
END
1
null
2
1
null
2
2
null
3
2
null
3
3
null
4
3
null
4
4
null
5
4
null
5
5
null
6
5
null
6
6
null
END
6
null
END
list
-1
조건: 리스트가 빈 경우 list == END
int tmp = nodes[free].next; nodes[free].info = item; nodes[free].next = END; list = free; free = tmp;
insert(john);
free
0
size
0
list
0
free
1
size
1
배열을 이용한 정렬 연결 리스트에서 삽입은 기존 정렬 리스트에서 다음과 같은 특성을 제 외하고는 차이가 없다. 첫째, 새 요소를 추가하기 위해 new를 사용하여 노드를 만들지 않 고, 빈 노드들의 리스트로부터 하나의 노드를 받는다. 둘째, 프로그래밍 할 때 사용하는 표 기 방식이 다르다. 기존에는 loc.info = item과 같은 형태로 프로그램을 작성하였지만 배열을 이용한 구현에서는 nodes[loc].info = item와 같이 배열 선택식이 포함된 형태의 표현식을 사 용하게 된다.
- 111 -
SortedArrayLinkedList의 insert 색인
info
next
색인
0
john
END
0
john
1
1
null
2
1
Robert
END
2
null
3
2
null
3
info
조건: 리스트 맨 끝에 삽입 loc == END
next
int tmp = nodes[free].next; nodes[free].info = item; nodes[free].next = END; nodes[prevLoc].next = free; free = tmp;
insert(Robert); 3
null
4
3
null
4
4
null
5
4
null
5
5
null
6
5
null
6
6
null
END
6
null
END
list
0
free
1
size
1
list
0
free
2
size
2
정렬 연결 리스트에서 삽입은 우선 삽입할 위치를 찾아야 한다. 위치를 찾으면 빈 리스트에 서 노드를 가지고 와서 이 노드에 데이터를 추가하고 이 노드를 결정된 위치에 삽입한다.
SortedArrayLinkedList의 insert 색인
info
next
색인
info
next
0
john
1
0
john
2
1
Robert
END
1
Robert
END
2
null
3
2
mary
1
3
null
4
3
null
4
4
null
5
4
null
5
5
null
6
5
null
6
6
null
END
6
null
END
조건: 일반적인 경우
int tmp = nodes[free].next; nodes[free].info = item; nodes[free].next = loc; nodes[prevLoc].next = free; free = tmp;
insert(mary);
list
0
free
2
size
2
list
0
- 112 -
free
3
size
3
SortedArrayLinkedList의 insert 색인
info
next
색인
info
조건: 리스트 맨 앞에 삽입 prevLoc == END
next
0
john
2
0
john
2
1
Robert
END
1
Robert
END
2
mary
1
2
mary
1
int tmp = nodes[free].next; nodes[free].info = item; nodes[free].next; = loc; list = free; free = tmp;
insert(Bob); 3
null
4
3
Bob
0
4
null
5
4
null
5
5
null
6
5
null
6
6
null
END
6
null
END
list
0
free
3
size
3
list
3
free
4
size
4
SortedArrayLinkedList의 delete 색인
info
next
색인
info
next
0
John
2
0
John
1
1
Robert
END
1
Robert
END
2
Mary
1
2
null
4
3
Bob
0
3
Bob
0
4
null
5
4
null
5
5
null
6
5
null
6
6
null
END
6
null
END
조건: 일반적인 경우
nodes[prevLoc].next = nodes[loc].next; nodes[loc].info = null; nodes[loc].next; = free; free = loc;
delete(Mary);
list
3
free
4
size
4
list
3
free
2
size
3
리스트 중간에 있는 Mary가 삭제되는 경우 이 노드는 빈 리스트로 옮겨져야 한다. 리스트의 특성 상 비정렬 리스트의 경우에는 맨 앞에 삽입하는 것이 가장 효율적이므로 삭제되는 노 드는 빈 리스트의 첫 노드가 된다.
- 113 -
제9장 재귀 프로그래밍 이 장에서는 재귀 프로그래밍에 대해 살펴본다.
9.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 재귀 프로그래밍: 프로그래밍 문제를 작은 문제로 분해하여 해결하는 매우 강력하고 유용한 문제 해결 방법이다. 이 때 작은 문제는 원 문제와 동일한 종류이다. 반복(iteration) 대신에 사용할 수 있는 방법 factorial, 조합, 하노이 타워 배열로 구현된 정렬 리스트에서 이진 검색 정렬 연결 리스트에서 역순 출력 정렬 연결 리스트에서 삽입
9.2. 재귀 기초
재귀(recursion) 재귀 호출: 호출 호출하는 메소드와 호출받는 메소드가 같은 메소드 호출 직접 재귀(direct recursion): 자기 자신을 바로 호출하는 메소드 재귀 간접 재귀(indirect recursion): 둘 이상의 메소드 호출이 최초의 재귀 호출 메소드으로 되돌아 오는 경우 재귀의 예: factorial if n = 0 ⎧1, n! = ⎨ ⎩ n × ( n − 1) × " × 1 if n > 0
예8.1) 4!=4±3±2±1=24 위 factorial의 정의를 다시 표현하면 if n = 0 ⎧1, n! = ⎨ ⎩ n × ( n − 1)! if n > 0
base case: 재귀가 아닌 경우 general case
이와 같이 자신의 작은 버전으로 자신을 정의하는 정의를 재귀 정의(recursive definition)라 한다. 정의
재귀 호출(recursive call)이란 호출하는 메소드와 호출 받는 메소드가 같은 메소드 호출을 말한다. 재귀 호출은 크게 직접 재귀와 간접 재귀로 나뉠 수 있다. 직접 재귀는 자기 자신 을 바로 호출하는 메소드를 말하며, 간접 재귀는 둘 이상의 메소드 호출이 최초의 호출 메 소드로 되돌아오는 경우를 말한다. 재귀 프로그래밍을 설명할 때 가장 많이 사용하는 예제
- 115 -
는 계승을 구하는 문제이다. 재귀 프로그래밍을 하기 위해서는 먼저 해결하고자 하는 문제 를 재귀 정의로 표현해야 한다. 재귀 정의란 자신의 작은 버전(원 문제와 형태가 일치해야 함)으로 자신을 정의하는 정의를 말한다. 이 때 자신의 작은 버전으로 정의하는 부분과 그 렇지 않은 부분으로 나뉘어 진다. 전자를 일반적인 경우라 하고, 그렇지 않은 경우를 기저 경우라 한다. 재귀 정의에는 반드시 기저 경우가 있어야 하며, 일반 경우를 반복적으로 적 용하면 결국 기저 경우에 도달해야 한다.
재귀 프로그래밍
factorial(4)
public static int factorial(int n){ if(n==0) return 1; else return (n * factorial(n-1)); }
4
recursive 방법 보통 선택문(if, switch)으로 구현 함수 호출이 많이 이루어진다.
3 n=4
24
2 n=3
6
public static int factorial(int n){ int val = 1; for(int i=2; i<=n; i++) val = val * i; return val; }
1 n=2
2
0 n=1
1
n=0 1
non-recursive 방법 보통 반복문(for, while)으로 구현
어떤 복잡한 문제를 해결해야 하는 경우 보통 문제를 보다 간단한 작은 문제로 분할하여 해 결하는 경우가 많다. 이런 형태의 문제 해결법을 분할정복법(divide-and-conquer)이라 한다. 문제를 분할하였을 때 작은 문제가 원 문제와 동일한 형태이면 재귀 방법을 사용하여 해결 할 수 있다. 재귀 프로그래밍은 보통 선택문을 사용하여 구현되며, 조건에 따라 재귀 호출 을 하거나 기저 경우를 실행하게 된다. 보통 재귀 방식으로 프로그래밍되어 있는 것은 반복 문을 사용하여 해결할 수도 있다. 두 가지 방법을 비교하였을 때 반복문을 사용하는 방식이 효율성 측면에서는 보통 우수하다. 이것은 재귀 방식에서는 함수 호출이 반본적으로 많이 일어나기 때문이다. 하지만 반복문을 사용하여 쉽게 해결하지 못하는 문제를 재귀 방법을 사용하면 매우 편리하게 해결할 수도 있다. 보통 재귀 프로그래밍에서는 기저 경우에 도달 할 때까지 계속 함수 호출만 일어나며, 기저 경우에서 최초 호출까지 되돌아오는 과정에서 실제 필요한 계산이 수행된다.
- 116 -
문제에 대한 재귀적 해결책 어떤 문제를 재귀적으로 해결하고자 할 때 고려해야 하는 네 가지 사항 문제를 같은 종류의 작은 문제로 재귀 정의를 할 수 있는가? 각 재귀호출 때 문제의 크기를 어떻게 줄이는가? base case 역할을 하는 문제의 인스턴스는 무엇인가? 각 재귀호출이 결국에는 base case에 도달할 수 있는가?
어떤 문제를 재귀적으로 해결하고자 할 때에는 다음 네 가지 사항을 우선 고려해야 한다. 첫째, 재귀적으로 문제를 해결하기 위해서는 문제를 같은 종류의 작은 문제로 재귀 정의를 할 수 있어야 한다. 둘째, 각 재귀 호출할 때 문제의 크기가 어떻게 축소되는지 검토해야 한다. 셋째, 기저 경우가 되는 문제의 인스턴스를 명확하게 정의해야 한다. 기저 경우는 한 가지 경우가 아니라 여러 가지 경우가 모두 기저 역할을 할 수도 있다. 넷째, 각 재귀호출 이 결국에는 기저 경우에 도달하는지 살펴보아야 한다. 이 부분은 다음 슬라이드에 있는 검 증과도 관련되어 있다.
재귀 프로그래밍의 검증 세 가지 질문 basebase-case question: question 메소드를 종료할 수 있는 비재귀적인 방법이 있나? base-case가 제대로 구현되어 있나? n=0일 때가 base-case이며, 이 때 1을 반환한다. smallersmaller-case question: question 재귀 호출이 원 문제의 작은 버전을 호출하고 있는가? 결국에는 base-case까지 도달하나? Æ 아니면 무한 재귀 재귀호출에서 인자는 n-1을 사용하고 있다. 일련의 재귀 호출은 인자의 값을 하나씩 감소하므로 결국에는 n이 0이된다. generalgeneral-case question: question 전체적으로 제대로 구현되어 있나? factorial(n-1)이 (n-1)!을 반환해주면 전체적으로 n!을 반환한다.
재귀적으로 알고리즘을 구현하였을 때 제대로 작성되었는지 검증하기 위해서는 다음 세 가 지 사항을 확인해야 한다. 첫째, 기저 경우를 제대로 구현하고 있는지 확인해야 한다. 기저 경우가 제대로 구현되어 있지 않으면 반목문이 무한 루프에 빠질 수 있듯이 무한적으로 재 귀 호출이 이루어질 수 있다. 둘째, 각 재귀호출이 원 문제의 작은 버전을 호출하고 있는지 확인해야 하며, 이를 통해 결국에는 기저 경우에 도달하는지 확인해야 한다. 셋째, 전체적으 로 원하는 결과를 반환하는지 검증해야 한다.
- 117 -
9.3. 재귀 프로그래밍의 예
search의 재귀 버전 UnsortedList의 search base case (1) element[startPosition]에 찾고자 하는 것이 있으면 true를 반환한다. (2) startPosition==size-1이고 element[startPosition]에 찾고자 하는 것이 없으면 false를 반환한다. general case: 남은 부분(startPosition에서 끝까지)을 검색한다. …
…
…
검색해야 할 부분
이미 검색한 부분
startPosition
search의 재귀 버전 – 계속 public boolean search(Object item) throws ListUnderflowException { if(isEmpty()) return new ListUnderflowException(“…”); return search(item, 0); } private boolean search(Object item, int startPosition){ if(item.equals(element[startPosition])) return true; else if(startPosition==(size-1)) return false; 사용자는 기존처럼 search(Object item) else return search(item, startPosition+1); 메소드를 호출한다. } public boolean search(Object item) throws ListUnderflowException { if(isEmpty()) return new ListUnderflowException(“…”); for(int i=0; i<size; i++) if(item.equals(element[i])) return true; return false; } …
…
…
1 2 3
배열을 이용한 비정렬 리스트 구현에서 검색 연산을 재귀적으로 작성할 수 있다. 하지만 이 경우는 각 재귀 호출에서 문제의 크기가 하나만 축소되므로 찾고자 하는 것이 배열에 없으 면 총 n번 재귀호출이 일어난다. 따라서 재귀적으로 구현할 수 있지만 이 예는 재귀보다는 반복문으로 해결하는 것이 보다 효과적이다.
- 118 -
조합(Combination) 순열(permutation): n개 중 r개를 비복원추출하여 어떤 순서로 순열 나열하는 경우의 수 n
Pr = n × ( n − 1) × " × ( n − r + 1)
조합(combination): 순서와 상관없이 n개의 요소에서 r개를 비복원추 조합 n! 출하는 경우의 수 n Pr n
조합의 재귀적 정의
n
Cr =
r!
=
r !( n − r ) !
⎧n ⎪ n C r = ⎨1 ⎪ C + C ⎩ n −1 r −1 n −1 r
if r = 1 if n = r if n > r > 1
( n − 1)! ( n − 1)! + ( r − 1)!( n − r ) ! r ! ( n − r − 1) ! ⎛ ( n − 1)! 1 ⎞ ( n − 1)! n ⎞ n! ⎛1 = + = = ( r − 1)!( n − r − 1) ! ⎜⎝ r n − r ⎟⎠ ( r − 1)!( n − r − 1) ! ⎜⎝ r ( n − r ) ⎟⎠ r ! ( n − r ) !
Cr =
n −1
C r − 1 + n −1 C r =
조합 – 계속 public static int combination(int n, int r){ if(r==1) return n; else if(n==r) return 1; else return (combination(n-1,r-1)+combination(n-1,r)); }
C(2,1) 2
combination(4,3)
C(3,2) C(2,2) 3
1
C(3,3) 1
확률과 통계에서 많이 사용되는 순열과 조합의 계산도 재귀적으로 해결할 수 있다. 하지만 조합의 경우는 뒤에 다시 언급하지만 같은 값을 여러 번 반복해서 계산하기 때문에 재귀적 으로 구현하는 것은 매우 비효율적이다. 조합은 이전에 살펴본 계승과 달리 두 가지 기저 경우가 존재한다. 이처럼 동일한 계산을 반복적으로 하는 재귀와 비정렬 리스트에서 검색 연산의 재귀 버전처럼 반복문에서 반복하는 횟수만큼 재귀 호출이 일어나는 경우는 재귀 버 전으로 구현하는 것은 비효율적이다.
- 119 -
하노이 타워
begin peg
aux peg
end peg
peg 1
peg 2
peg 3
제약 (1) 한번에 하나의 링만 움직일 수 있다. (2) 작은 링 위에 큰 링을 올려 놓을 수 없다. 힌트
하노이 타워는 재귀 프로그래밍을 설명할 때 많이 소개되는 문제이다. 하노이 타워는 세 개 의 막대기와 n개의 링으로 구성되어 있다. 크기가 큰 순서부터 쌓여 있는 링들을 보조 막대 기를 이용하여 모두 목표 막대기로 이동하는 문제이다. 이 때 한번에 하나의 링만 움직일 수 있으며, 작은 링 위에 큰 링을 올려놓을 수 없다는 제약이 있다.
하노이 타워 – 계속 aux peg
begin peg
end peg
begin peg
aux peg
end peg
하노이 타워 문제는 다음과 같이 해결할 수 있다. 첫째, n-1개의 링을 목표 막대기를 보조 막대기로 활용하여 보조 막대기로 이동한다. 둘째, 가장 큰 링을 목표 막대기로 옮긴다. 셋 째, 보조막대기에 있는 n-1개의 링을 시작 막대기를 보조 막대기로 이용하여 목표 막대기로 옮긴다.
- 120 -
하노이 타워 – 계속 하노이 타워 알고리즘 단계 1. n-1개의 링을 peg 1(시작 peg)에서 peg 2(보조 peg)로 옮겨라. 단계 2. n번째 링을 peg 3(목적 peg)으로 옮겨라. 단계 3. peg 2(보조 peg)에 있는 n-1개의 링을 peg 3(목적 peg) 으로 옮겨라. public static void doTowers(int numRings, int begPeg, int auxPeg, int endPeg){ if(numRings>0){ doTower(numRings-1, begPeg, endPeg, auxPeg); system.out.println(“Move ring from peg ”+ begPeg + “ to peg ” + endPeg); doTower(numRings-1, auxPeg, begPeg, endPeg); } }
위 프로그램을 수행하면 n개의 링으로 구성된 하노이 타워 문제를 해결하는 방법을 출력하 여 준다.
이진 검색
public boolean search(Object item){ if(isEmpty()) return new ListUnderflowException(“…”); Comparable x = (Comparable)item; return search(x, 0, size-1); } private boolean search(Comparable item, int first, int last){ if(first>last) return false; else{ int mid = (first+last)/2; int comp = item.compareTo(element[mid]); if(comp==0) return true; else if(comp<0) return search(item, first, mid-1); else return search(item, mid+1,last); } }
Base case (1) first>last Æ false를 반환 (2) item.compareTo(element[mid])==0 Æ true를 반환 General case (1) item.compareTo(element[mid])<0 Æ search(first,mid-1) (2) item.compareTo(element[mid])>0 Æ search(mid+1,last)
이 절 처음에 선형 검색을 재귀 방법으로 구현한 예를 살펴본 바 있다. 그 때 언급한 바와 같이 선형 검색을 재귀적으로 해결할 수 있으나 성능 측면에서 매우 비효율적이었다. 좀 더 정확하게 표현하면 최악의 경우 n번의 재귀 호출이 필요하였다. 이진 검색을 재귀 방법을 구현하면 최악의 경우 log2n번의 재귀 호출이 필요하므로 이와 같은 경우에는 재귀 호출에 따른 오버헤드(함수 호출 비용)가 문제가 되지 않는다.
- 121 -
연결 리스트에서 재귀 정렬 연결 리스트에서 역순으로 출력 list
A
B
2
C
D
E
1 이것을 출력: E,D,C,B, A
이 부분을 역순으로 출력: E,D,C,B
public void PrintReverse() { revPrint(list); } private void revPrint(ListNode node){ if(node != null){ revPrint(node.next); System.out.println(“ ” + node.info); } }
연결구조 방식의 정렬 리스트에 저장되어 있는 요소들을 역순으로 출력하고 싶다. 이 문제 는 재귀 방법을 사용하지 않을 경우에는 스택을 이용하는 방법을 생각해 볼 수 있다. 스택 은 LIFO 구조이므로 이 특성을 사용하면 쉽게 역순으로 출력하는 메소드를 작성할 수 있다. 먼저 리스트의 각 노드를 방문하면서 방문하는 순서대로 스택에 요소들을 push한다. 그 다 음에 리스트의 모든 노드를 방문하였으면 스택에서 차례대로 pop하여 출력하면 역순으로 출력된다. 재귀적으로 이 문제를 고려하면 문제를 다음과 같이 다시 서술할 수 있다. 첫 노드를 제외한 나머지 노드들을 역순으로 출력한다. 첫 노드를 출력한다. 첫 번째 부분은 원 문제의 작은 버전이므로 위 서술을 이용하여 쉽게 역순으로 출력하는 재 귀 방식의 프로그램을 작성할 수 있다. 이처럼 재귀적으로 구현하기는 쉬우나 반복 방식으 로 구현하는 것이 어려운 문제도 있다.
연결 리스트에서 재귀 – 계속 private ListNode insert(ListNode subList, Comparable item){ if(subList==null || item.compareTo(subList.info)<0){ ListNode newNode = new ListNode(); newNode.info = item; newNode.next = subList; 정렬 리스트에서 삽입 return NewNode; } Base Case: else{ (1) 부분 리스트가 비어있으면 subList.next = insert(subList.next, item); 빈 리스트에 삽입 return subList; (2) 삽입하고자 하는 요소가 } 부분 리스트의 첫 요소보다 } 작으면 리스트 맨 앞에 삽입 public void insert(Object item){ General Case: if(item==null) insert(sublist.next, item) return new NullPointerException(“…”); Comparable x = (Comparable)item; list = insert(list, x); }
- 122 -
연결 리스트에서 재귀 – 계속 list
6
11
2
20
11
1
insert(list,15)
15
20
15
20
15
20
1 6
6
11
20 11
insert(subList.next,15) 2 11
list
20
6
insert(subList.next,15) 3 20
15
11
20
정렬 리스트에서 삽입 문제도 재귀적으로 구현할 수 있다. 하지만 이 예도 선형 검색과 마 찬가지로 최악의 경우 n번의 재귀 호출이 필요하므로 재귀적으로 구현하기에 적합한 예는 아니다. 하지만 삽입 문제를 이처럼 재귀적으로 구현하면 모든 경우를 한 가지 경우(리스트 맨 앞에 삽입)로 바꾸어 프로그래밍할 수 있다. 원래 연결구조 방식의 정렬 리스트에서 삽 입은 크게 네 가지 경우를 고려하여 프로그래밍해야 하는데, 재귀적으로 구현하면 항상 맨 앞의 삽입하는 경우로 바꾸어 프로그래밍할 수 있다.
9.4. 재귀의 동작 원리
재귀의 동작 원리 public static int factorial(int n){ if(n==2) return 2; else return (n * factorial(n-1)); } factorial(3)/ call factorial(2)
public static void main(String[] args){ int result = factorial(4); System.out.println(“4! = ”+result); }
parameter
2
Return Address 1000 return 2
factorial(4)/ call factorial(3)
Return Value
?
parameter
3
2
Return Address 1000 return 3*2 call
return Return Value main/ call factorial(4)
runtime stack
?
parameter
4
Return Address
100
Return Value
?
6
return 4*6 24
자바 프로그래밍 언어는 C/C++ 언어와 마찬가지로 어떤 메소드 또는 함수가 실행되면 그 함수를 위한 작업 공간이 메모리에 할당된다. 이 공간을 스택 프레임(stack frame) 또는
activation record라 한다. 이 공간에는 복귀 주소(return address), 반환될 값, 매개 변수, 지 역 변수 등이 저장된다. 이 공간을 스택 프레임이라고 불리는 이유는 논리적으로 메소드가 종료되지 않고 또 다른 메소드가 호출될 때 새 메소드의 작업 공간이 기존 작업 공간 위에
- 123 -
쌓여지는 형태가 되기 때문이다. 이 예제처럼 재귀 방식의 계승 메소드가 호출되면 계승 메 소드를 위한 작업 공간이 할당된다. 이 때 이 메소드에서 다시 재귀 호출이 이루어지면 기 존 작업 공간은 스택에 push되고 또 다른 작업 공간이 할당되어 사용된다. 이렇게 하여 기 저 경우에 도달되어 현재 메소드가 종료되면 스택에서 작업 공간을 차례대로 하나씩 가지고 오면서 처리된다.
9.5. 기타 어떤 문제가 재귀적으로 정의되면 보통 재귀 방식으로 구현하여 쉽게 해결할 수 있다. 하지 만 이렇게 구현된 결과가 효율성 측면에서는 만족스럽지 않을 수 있다. 이 때에는 재귀를 제거하여 효율성을 제고할 수 있다. 또한 프로그래밍 언어가 재귀 호출을 지원하지 않으면 이 방법이 문제를 해결하는 좋은 수단이 될 수 있다.
재귀의 제거 재귀 방식으로 구현된 메소드를 재귀 호출을 사용하지 않도록 바꾸는 방법 방법 1. 반복문 사용 모든 경우에 반복문을 바꿀 수 있는 것은 아니다. 꼬리 재귀(tail recursion)이면 쉽게 바꿀 수 있다. 재귀 방법 2. 스택 사용 리스트의 역순 출력: 리스트의 각 노드를 차례대로 방문하면서 노드의 요소 값을 스택에 push한 다음에 스택에서 하나씩 pop 하여 출력하면 역순으로 출력할 수 있다.
재귀 방식으로 작성된 메소드를 재귀를 사용하지 않도록 바꾸는 방법은 크게 두 가지 방법 이 있다. 첫째, 반복문을 사용하도록 바꾸는 것이다. 둘째, 스택을 사용하는 것이다. 전자는 꼬리 재귀 형태가 아니면 쉽게 반복문으로 바꿀 수 없다는 문제가 있고, 후자는 스택을 직 접적으로 구현해야 하기 때문에 효율성이 크게 제고되지 않을 수도 있다.
- 124 -
재귀 Æ 반복 꼬리 재귀: 메소드 내에 모든 재귀 호출이 메소드의 마지막 문장인 경우 base case가 되면 반복이 종료되도록 반복문을 구성하여 쉽게 재귀를 제거할 수 있음 이 때 루프 변수는 재귀 호출에서 값이 변하는 인자 꼬리 재귀가 아니면 쉽게 바꿀 수 없다. boolean search(Comparable item){ int loc = 0; boolean found = false; while(loc<size && !found){ if(item.compareTo(element[loc])==0) found = true; else loc++; } return found; }
boolean search(Comparable item, int start){ if(item.compareTo(element[start])==0) return true; else if(start==size-1) return false; else return search(item, start+1); }
재귀 방식으로 작성된 모든 메소드를 반복문으로 사용하도록 바꿀 수 없다. 재귀 방식으로 작성된 메소드 중에 재귀 호출이 모두 메소드의 마지막 문장이면 이런 재귀를 꼬리 재귀
(tail recursion)라 한다. 메소드의 마지막 문장이라는 것은 물리적으로 그 메소드의 마지막 문장이라는 것이 아니라, 그 문장 이후에 더 이상 추가로 수행해야 하는 문장이 없는 경우 를 말한다. 꼬리 재귀인 경우에는 기저 경우에 반복이 종료되도록 반복문을 구성하여 쉽게 반복문을 사용하는 버전으로 바꿀 수 있다. 이 때 재귀 호출에서 값이 변하는 인자를 반복 문의 제어 변수로 사용한다.
재귀의 사용 여부 두 가지 측면: 명확성, 효율성 명확성: 재귀가 보통 알고리즘을 이해하기가 더 쉽다. 예8.2) 역순으로 정렬 연결 리스트 출력 효율성: 재귀가 당연히 공간과 시간 측면에서 모두 나쁘다. 절대적인 것은 아님: 문제, 컴퓨터, 컴파일러에 따라 다를 수 있다. 예8.3) factorial(n): n+1개의 stack frame이 필요 Æ O(n) 문제가 본질적으로 재귀에 맞지 않을 수 있다. Æ 조합: 같은 계산이 무수히 반복 Æ O(2N) C(5,3) C(4,3)
C(4,2) C(3,1)
C(3,2) C(2,1)
C(3,2)
C(2,2)
C(2,1)
C(3,3)
C(2,2)
어떤 문제가 주어졌을 때 이 문제를 재귀 방식으로 해결할 것인지 아니면 재귀를 사용하지 않을 것인지 결정하기 위해서는 명확성과 효율성 두 가지 측면을 고려해야 한다. 앞서 언급 한 바와 같이 대부분의 경우 재귀를 사용하는 것보다 재귀를 사용하지 않는 것이 성능면에 서 좋다. 하지만 이것이 절대적인 것은 아니다. 어떤 프로그래밍 언어는 재귀를 효과적으로 처리하도록 설계된 언어도 있다. 하지만 재귀 트리를 구성하였을 때 조합과 같이 같은 계산 을 무수히 반복되는 경우는 재귀 방식으로 해결할 수 있지만 재귀에 맞지 않는 문제이다.
- 125 -
명확성 측면에서는 프로그래머마다 다를 수 있지만 보통 재귀적으로 작성된 메소드가 보다 단순하고 이해하기 쉽다. 특히 프로그램 문장 수 측면에서는 재귀 방식으로 구현하였을 경 우가 대부분 적다. 따라서 어떤 문제가 주어지면 우선 재귀적으로 해결 가능한지 살펴본다. 해결할 수 있는 경우에는 재귀적으로 해결하여도 성능에 큰 영향이 없는지 살펴본다. 만약 재귀적으로 구현하는 것이 성능에 나쁜 영향을 주면 앞서 살펴본 방법을 이용하여 재귀를 사용하지 않는 버전으로 바꾸어 사용한다.
- 126 -
제10장 이진 검색 트리 이 장에서는 트리 형태의 자료구조에 대해 살펴본다. 특히 트리 형태의 자료구조 중 이진 검색 트리(binary search tree)에 대해 집중적으로 살펴본다.
10.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 트리 개요 이진 트리 개요 순회 방법 이진 검색 트리 ADT 검색 삽입 삭제 순회 이진 트리 균형 맞추기
10.2. 트리
트리 구조 선형 연결 리스트는 성능 측면에서는 배열을 이용한 리스트 구현에 비해 우수하지 못하지만 공간 활용 측면에서는 우수하다. 선형 연결 리스트의 또 다른 단점은 임의 접근을 할 수 없으므로 정렬된 리스트에서도 이진 검색을 할 수 없다는 것이다. 선형 연결 리스트에서 하나의 노드는 오직 하나의 다른 노드만 가리킬 수 있다. 트리(tree)는 트리 루트(root)라고 하는 유일한 시작 노드를 루트 노드 가지며, 각 노드는 여러 개의 자식 노드를 가질 수 있는 구조로서, 구조 루트에서 각 노드까지의 경로가 유일한 구조를 구조 말한다. 트리는 재귀 구조(recursive structure)이다. 구조 각 노드를 기준으로 그 노드가 루트가 되는 부분트리(subtree)를 부분트리 만들 수 있다.
7장에서 살펴본 연결구조 방식의 리스트는 5장에서 살펴본 배열을 이용한 리스트에 비해 공간 활용 측면에서는 우수하지만 성능 측면에서는 오히려 배열을 이용한 리스트가 더 우수 하다. 특히 정렬 리스트의 경우에는 배열은 임의 접근이 가능하므로 이진 검색을 할 수 있
- 127 -
지만 연결구조 방식의 경우에는 정렬되어 있음에도 불구하고 이진 검색을 할 수 없다. 이런 단점을 해결하는 한 가지 방법은 트리(tree) 구조를 사용하는 것이다. 트리는 루트(root)라고 하는 유일한 시작 노드를 가지며, 각 노드는 여러 개의 자식 노드를 가질 수 있고, 루트에 서 각 노드까지의 경로(path)가 유일해야 하는 구조이다. 노드 A에서 노드 B까지의 경로란 노드 A와 노드 B를 연결하는 간선들에 의해 인접한 일련의 노드들의 집합을 말한다. 트리 의 정의에 의하면 7장에서 살펴본 연결구조 방식의 선형 리스트는 자식 노드를 최대 하나 만 가지는 일종의 트리이다. 트리 구조는 재귀 구조라 한다. 이것은 트리에 있는 각 노드를 기준으로 그 노드가 루트가 되는 트리를 만들 수 있기 때문이다. 이런 트리를 원 트리의 부 분 트리(subtree)라 한다.
트리 구조 – 계속 root (루트 노드) internal node, intermediate node (중간 노드, 내부 노드)
트리가 아님 Æ 그래프
subtree (부분 트리)
leaf node (단말 노드)
루트: 부모가 없는 노드 단말: 자식이 없는 노드
루트에서 각 노드까지의 경로가 유일해야 한다. 즉, 부모가 하나이어야 한다.
트리 용어 루트 노드(root node): 부모가 없는 유일한 노드 단말 노드(leaf node): 자식이 없는 노드 중간 노드(internal node): 단말 노드를 제외한 모든 노드 형제 노드(sibling node): 부모가 같은 노드 조상 노드(ancestor node): 노드에서 루트 노드까지 경로 상에 있는 모든 노드 후손 노드(descendant node): 노드에서 단말 노드까지 경로 상에 있는 모든 노드 노드의 레벨: 루트 노드로부터의 거리 루트 노드는 0 레벨에 위치하며, 루트로부터 각 노드까지의 경로의 길이가 그 노드의 레벨이 된다. 트리의 높이: 트리의 루트로부터 가장 먼 노드까지의 경로의 길이
트리 구조는 앞서 설명한 바와 같이 유일한 시작 노드인 루트 노드를 가진다. 이 노드만 유 일하게 부모 노드가 없다. 또한 자식이 없는 노드를 단말 노드(leaf node)라 하며, 단말 노 드를 제외한 모든 노드를 중간 노드 또는 내부 노드라 한다. 트리 구조가 되기 위해서는 루 트에서 각 노드까지의 경로가 유일해야 한다. 이것을 다시 말하면 각 노드는 오직 하나의
- 128 -
부모 노드를 가져야 한다. 부모가 같은 노드들을 형제 노드(sibling node)라 한다. 노드에서 루트노드까지 경로 상에 있는 모든 노드는 노드의 조상 노드(ancestor node)가 되며, 거꾸로 한 노드부터 단말 노드까지 경로 상에 있는 모든 노드는 그 노드의 후손 노드(descendant
node)가 된다. 트리의 레벨(level)이란 루트 노드부터의 거리를 말한다. 루트 노드는 레벨 0 에 위치하며 루트 노드의 바로 자식 노드들은 레벨 1에 위치한다고 말한다. 트리의 노드 중 가장 레벨이 높은 위치에 있는 노드에 의해 트리의 높이(height)가 결정된다.
이진 트리 이진 트리(binary tree): 트리 각 노드가 최대 두 개의 자식 노드만을 가질 수 있는 트리 각 레벨 l에 있을 수 있는 최대 노드의 수: 2l 높이가 h인 트리에 있을 수 있는 최대 노드의 수: 2h+1-1 20 + 21 +"+ 2h =
2h+1 − 1 h+1 = 2 −1 2 −1
root
level 0
A
level 1 B
C
level 2 D
E
F
level 3 G height(depth): 3
N개의 노드로 만들 수 있는 트리의 최소 높이: ⎣⎢ log 2 N ⎦⎥ 2h ≤ N < 2h+1 (노드의 수가 2 일 때 트리의 높이는 h) 2h − 1 < 2h < 2h+1 − 1 h
h ≤ log2 N < h + 1
H
I
J leaf
G는 B의 자손 노드 C는 I의 조상 노드 G는 D의 왼쪽 자식(left child) H는 D의 오른쪽 자식(right child) G와 H는 형제 노드(sibling)
트리 중에 각 노드의 자식 수가 최대 2인 트리를 이진 트리(binary tree)라 한다. 따라서 이 진 트리의 각 레벨 에 있을 수 있는 최대 노드의 수는 이다. 또한 높이가 인 트리에 있 이다. 개의 노드를 만들 수 을 수 있는 최대의 노드의 수는
있는 최대의 높이는 -1이다. 반대로 개의 노드로 만들 수 있는 트리의 최소 높이는
이다. 이것은 의 범위를 일반화하여 ≦ 로 생각할 수 있으며, 트리의 높이를 최소화하기 위해서는 각 레벨에 있는 노드의 수가 최대가 되어야 한다는 것을 고려 하면 쉽게 이해할 수 있다.
- 129 -
이진 트리의 종류
포화 이진 트리(full binary tree):
완전 이진 트리(complete binary tree):
모든 단말 노드가 같은 레벨에 있으며, 모든 중간 노드는 두 개의 자식 노드를 가지는 경우
마지막 레벨을 제외하면 포화 이진 트리 이며, 촤하위 레벨의 단말 노드들은 왼쪽에 서부터 채워진 형태로 되어 있는 경우
단말 노드의 수가 n이면 전체 노드의 수는 N=2n-1이다.
완전 이진 트리에서 중간 노드의 자식 수가 2일 때 단말 노드의 수가 n이면 전체 노드의 수는 N=2n-1이다.
이진 트리에서 모든 단말 노드는 같은 레벨에 있고 모든 중간 노드는 두 개의 자식 노드를 가지는 트리를 포화 이진 트리(full binary tree)라 한다. 또한 마지막 레벨을 제외하면 포화 이진 트리이며, 최하위 레벨의 단말 노드들은 왼쪽에서부터 채워진 형태로 되어 있는 트리 를 완전 이진 트리(complete binary tree)라 한다.
10.3. 이진 검색 트리
이진 검색 트리 이진 검색 트리(binary search tree)란 트리 각 노드의 키 값이 왼쪽 자손 노드들의 키 값보다는 항상 크고, 오른쪽 자손 노드들의 키 값보다는 항상 작은 이진 트리를 말한다. 60
45 (왼쪽 부분 트리) left subtree
63
41
40
55
43
62
(오른쪽 부분 트리) right subtree
65
50
왼쪽 부분 트리의 노드의 키 값<루트 노드의 키 값<오른쪽 부분 트리의 노드의 키 값
이진 검색 트리(binary search tree)란 각 노드의 키 값이 왼쪽 자손 노드들의 키 값보다는 항상 크고, 오른쪽 자손 노드들의 키 값보다는 항상 작은 이진 트리를 말한다.
- 130 -
이진 검색 트리의 예 60
height: 9 편향 이진 트리 (skewed binary tree)
40
50
41 45
63
40
55
43 45
41
55
62
65
45
62 50
40
43
50
43 height: 3
60
65
60
height: 4 41
55
63 62 63 65
이진 검색 트리는 트리를 구성한 순서에 따라 다양한 모습을 취할 수 있다. 위 슬라이드에 는 같은 종류의 노드들로 구성한 세 가지 다른 모습의 이진 검색 트리를 보여주고 있다. 여 기서 알 수 있듯이 최악의 경우에는 한쪽 방향으로 편향될 수 있으며, 이 경우에는 기존 연 결구조 방식의 리스트와 차이가 전혀 없다.
이진 트리 순회 순회(traversing): 트리에 있는 모든 노드를 방문하는 것 순회 트리에 있는 모든 노드를 순회할 때 가장 널리 사용되는 방법 전위(preorder) 순회: 루트 노드를 방문 Æ 왼쪽 부분 트리에 있는 전위 노드들을 방문 Æ 오른쪽 부분 트리에 있는 노드들을 방문 중위(inorder) 순회: 왼쪽 부분 트리에 있는 노드들을 방문 Æ 루트 중위 노드를 방문 Æ 오른쪽 부분 트리에 있는 노드들을 방문 후위(postorder) 순회: 왼쪽 부분 트리에 있는 노드들을 방문 Æ 후위 오른쪽 부분 트리에 있는 노드들을 방문 Æ 루트 노드를 방문 이들 순회 방법은 루트 노드를 방문하는 순서에 의해 구분되며, 구분 위 세 가지 순회 방법은 공통적으로 왼쪽 부분 트리는 항상 오른쪽 부분 트리보다 먼저 방문한다. 중위 순회 방법으로 순회하면 작은 값부터 순서대로 방문하게 된다.
순회(traverse)란 구조에 있는 모든 요소를 최소 한번씩 방문하는 것을 말한다. 연결구조 방 식의 리스트에서는 첫 노드부터 리스트에 있는 모든 노드를 방문할 때까지 노드의 연결을 따라가면서 노드들을 방문한다. 이진 트리에서 순회는 한 노드에서 방문할 수 있는 다음 노 드가 최대 두 개 있으므로 다양하게 순회를 할 수 있다. 트리를 순회할 때 가장 널리 사용 되는 방법에는 전위(preorder), 중위(inorder), 후위(postorder) 세 가지 방법이 있다. 이들은 루트를 방문하는 순서에 의해 구분되며, 항상 왼쪽 서브 트리를 오른쪽 서브 트리보다 먼저 방문한다. 세 가지 방법 중 중위 방법으로 순회하면 작은 값부터 순서대로 방문하게 된다.
- 131 -
이진 트리 순회의 예 60
45
41
Preorder: 60 45 41 55 63 65 Inorder: 41 45 55 60 63 65 Postorder: 41 55 45 65 63 60
63
55
65
50
40
Preorder: 50 40 45 43 47 55 52 62 60 65 Inorder: 40 43 45 47 50 52 55 60 62 65 Postorder: 43 47 45 40 52 60 65 62 55 50
55
45
43
52
47
62
60
65
BST Interface Import java.util.Iterator; public interface BST{ int INORDER = 1; int PREORDER = 2; int POSTORDER = 3; boolean isEmpty(); boolean isFull(); void clear(); int numOfNodes(); boolean search(Object item); Object retrieve(Object item); void insert(Object item); boolean delete(Object item); Iterator iterator(int type); }
이진 검색 트리는 중복 요소의 추가를 허용하지 않는다. 참조 방식으로 구현한다.
순회 방법
이진 검색 트리의 인터페이스는 위 슬라이드와 같다. 인터페이스의 정의에서 알 수 있듯이 기존 리스트 인터페이스와 큰 차이가 없다. 다만 size 메소드 대신에 numOfNodes라는 메소 드를 사용하고 있고, 세 종류의 순회 방법을 나타내기 위한 상수 값들이 추가로 정의되어 있다.
- 132 -
public class BinarySearchTree implements BST{ protected class BSTNode{ public Object info; public BSTNode left; public BSTNode right; } protected class TreeIterator implements Iterator{ LinkedQueue traverseQueue; public TreeIterator(int type){…} public boolean hasNext(){…} public Object next(){…} public void remove(){…} private void preOrder(BSTNode node){…} node.left private void inOrder(BSTNode node){…} private void postOrder(BSTNode node){…} } protected BSTNode root = null; protected int numOfNodes = 0; public BinarySearchTree(){} public boolean isEmpty(){ return (root==null); } public boolean isFull(){ return false; } void clear() { … } public int numOfNodes(){ return numOfNodes; } public boolean search(Object item){…} public Object retrieve(Object item){…} public void insert(Object item){…} public boolean delete(Object item){…} public Iterator iterator(int type){ return new TreeIterator(type); } }
node.right node.info
트리의 각 노드를 나타내기 위해 BSTNode라는 내부 클래스를 사용한다. BSTNode 클래스 는 7장에서 사용한 ListNode와 달리 두 가지 연결을 나타내는 멤버변수가 필요하다. left는 노드의 왼쪽 자식을 가리키는 멤버변수이고, right는 노드의 오른쪽 자식을 가리키는 멤버 변수이다. TreeIterator는 순회할 때 사용할 수 있는 반복자 클래스이다.
NumOfNodes 재귀적 방법으로 public int size(){ return size(root); } private int size(BSTNode node){ if(node == null) return 0; // leaf node else return(size(node.left)+size(node.right)+1); }
Tip. 트리는 재귀적 구조이므로 재귀적 방법으로 구현하는 것이 쉽다.
트리에 있는 노드의 개수는 삽입할 때마다 증가하고 삭제될 때마다 감소하는 numOfNodes 라는 멤버변수를 통해 언제든지 알 수 있다. 하지만 노드의 개수를 그때마다 계산할 수도 있다. 트리에 있는 노드의 개수를 재귀 방식으로 구현하면 위 슬라이드와 같다. 이 메소드 에서 재귀 호출은 트리에 있는 노드의 개수만큼 호출되므로 효율적이지 못하다. 하지만 트 리는 그 자체가 재귀 구조이므로 효율성을 고려하지 않으면 필요한 대부분의 연산을 재귀 방식으로 쉽게 구현할 수 있다.
- 133 -
NumOfNodes 비재귀적 방법으로 E F C
C
E
F
A
count = 0; A 트리가 empty가 아니면 다음을 스택을 스택 하나 생성 루트 노드를 스택에 push 스택이 empty가 아니면 다음을 반복 현재 노드를 스택 top에 있는 노드를 가리키도록 함 스택에서 노드를 하나 pop한 다음에 count를 1 증가 만약 현재 노드가 왼쪽 자식이 있으면 그 자식을 스택에 push 만약 현재 노드가 오른쪽 자식이 있으면 그 자식을 스택에 push return count;
재귀 방식을 사용하지 않고 노드의 개수를 계산하여 보자. 재귀 방식의 구현을 보면 재귀 호출이 같은 문장에서 두 번 이루어지므로 꼬리 재귀가 아니다. 따라서 쉽게 반복문을 이용 하여 해결하기가 어렵다. 대신에 스택을 직접 사용하는 방법을 이용하여 재귀를 제거하는 방법을 고려하는 것이 적절하다. 비재귀적 방법으로 트리에 있는 노드의 개수를 구하는 알 고리즘은 위 슬라이드와 같다.
search 재귀 방법으로 private boolean search(Comparable item, BSTNode node){ if(node == null) return false; int comp = item.compareTo(node.info); if(comp<0) return search(item, node.left); else if(comp>0) return search(item, node.right); else return true; } public boolean search(Object item){ if(isEmpty()) throw new TreeUnderflowException(“…”); if(item==null) throw new NullPointerException(“…”); Comparable x = (Comparable)item; return search(x, root); }
60
45
63
41
55
62
65
전형적인 이진 검색 40
43
50
이진 검색 트리에서 검색은 전형적으로 이진 검색을 하게 된다. 먼저 루트와 비교한 다음에 루트보다 찾고자 하는 요소가 작으면 루트의 왼쪽 서브트리로 이동하게 된다. 따라서 한번 비교할 때마다 한쪽 서브트리는 후보에서 제외된다. 트리가 완전 이진 트리이고, 트리에 개의 노드가 있다면 이 트리에서 재귀 호출은 최대 트리의 높이인 따라서 이 경우에는 재귀 방식으로 구현하여도 문제가 없다.
- 134 -
번 이루어진다.
search 반복 방법으로 found = false; 트리가 비어 있으면 return false; moreToSearch = true; node = root; found가 true이거나 moreToSearch가 false일 때까지 다음을 반복 node와 item을 비교 같으면 return true; item이 node보다 작으면 node = node.left; item이 node보다 크면 node = node.right; moreToSearch = (node != null) return found
검색의 재귀 구현은 꼬리재귀이므로 이 경우에는 쉽게 반복문을 이용하여 재귀를 제거할 수 있다.
insert 1 insert(5)
2 insert(9)
5
5 insert(8)
9 3 insert(7)
4
insert(3)
5
7
3
3
9
7
6
7
9
3 12
9
4
7
12
5 9
7
5
7
insert(12)
8 3
insert(4)
5
5
9
8
insert(6)
5
5
6 3
8
6
8
9
12
7
8
항상 단말노드로 추가된다. 삽입되는 순서가 트리의 모습을 결정한다.
이진 검색 트리에서 새 노드는 항상 단말노드로 추가되며, 이 추가가 이진 검색 트리의 특 성을 유지해야 한다. 따라서 추가할 노드의 키 값을 트리에서 검색하듯이 기존 키 값들과 비교하여 삽입 위치를 찾아 추가하게 된다. 이런 방식으로 추가되기 때문에 이진 검색 트리 의 모습은 키 값들이 삽입되는 순서에 의해 결정된다.
- 135 -
insert public void insert(Object item){ if(item==null) throw new NullPointerException(“…”); Comparable x = (Comparable)item; root = insert(x, root); } private BSTNode insert(Comparable item, BSTNode node){ if(node == null){ BSTNode newNode = new BSTNode(); newNode.info = item; newNode.left = null; newNode.right = null; numOfNodes++; return newNode; } int comp = item.compareTo(node.info); if(comp<0) node.left = insert(item, node.left); else if(comp>0) node.right = insert(item, node.right); else node.info = item; return node; }
delete delete(8)
delete(2)
5
3
9
4
3
12
7
6
5
5
2
9
4
8
7
5
9
4
12
3
6
12
7
6
4
9
3
8
12
7
6
단말 노드의 삭제
자식이 하나인 노드의 삭제
바로 삭제
남은 자식과 노드를 교체
8
이진 검색 트리에서 노드의 삭제는 추가보다 복잡하다. 추가는 항상 단말노드에서 이루어지 지만 삭제는 단말노드뿐만 아니라 중간노드도 삭제될 수 있다. 따라서 삭제는 크게 세 가지 경우로 나뉘어 고려할 수 있다. 첫째, 단말노드의 삭제 둘째, 자식이 하나 있는 중간노드의 삭제 셋째, 자식이 둘인 중간노드의 삭제 단말노드의 삭제는 그 노드를 삭제하면 그것으로 삭제가 완료된다. 프로그램 측면에서 보면 삭제할 노드의 부모 노드의 연결 중 삭제할 노드에 대한 연결 값을 null 값으로 바꾸면 된 다. 자식이 하나 있는 중간노드의 삭제는 그것의 부모 노드의 연결 중 자신에 대한 연결을 자신의 자식 노드에 대한 연결로 바꾸면 된다.
- 136 -
delete – 계속 delete(9) 5
3
9
4
알고리즘
5
3
12
7
4
8
6
삭제할 노드를 찾는다. 삭제할 노드가 단말노드이면 노드 삭제 삭제할 노드가 단말노드가 아닌 경우 오른쪽 자식 노드가 없으면 왼쪽 자식 노드를 한 단계 위로 왼쪽 자식 노드가 없으면 오른쪽 자식 노드를 한 단계 위로 자식이 둘 다 있으면 predecessor를 찾아 이것을 삭제할 노드의 값과 바꿈 predecessor 노드를 삭제
8
12
7
6
자식이 둘인 노드의 삭제
왼쪽 부분 트리 중 가장 큰 노드(predecessor)와 교체 - 이 노드는 항상 왼쪽 부분 트리의 모든 노드보다는 크고, 오른쪽 부분 트리의 모든 노드보다는 작은 노드이다. 오른쪽 부분 트리의 가장 작은 노드와 교체 가능
자식이 둘 다 있는 중간 노드의 삭제는 그것을 바로 삭제할 수 없다. 이것은 삭제할 노드의 부모 노드의 연결을 삭제할 노드의 한쪽 자식만 가리키도록 변경할 수 없기 때문이다. 노드 가 삭제되어도 트리는 여전히 이진 트리 구조를 유지해야 한다. 이것을 하기 위한 한 가지 방법은 삭제할 노드를 트리에 있는 다른 값으로 대체하는 것이다. 이진 트리의 구조를 유지 하면서 삭제할 노드를 대체할 수 있는 값은 정확하게 두 개 존재한다. 하나는 삭제할 노드 에 있는 키 값보다는 작지만 가장 큰 값이고, 다른 하나는 삭제할 노드에 있는 키 값보다는 크지만 가장 작은 값이다. 전자는 노드의 왼쪽 서브트리에서 가장 큰 값이고, 후자는 노드 의 오른쪽 서브트리에서 가장 작은 값이다. 이 장에서는 왼쪽 서브트리에서 가장 큰 값으로 대체하여 삭제를 수행한다. 이 때 대체함으로써 삭제가 완료되는 것은 아니고, 대체한 후에 대체에 사용된 노드를 실제로 트리에서 삭제해야 한다. 이 때 주목해야 하는 것은 대체에 사용된 노드는 단말노드이거나 자식이 하나 밖에 없는 중간 노드이다. 그 이유는 만약 자식 이 둘 다 있는 중간 노드이면 그것의 오른쪽 자식이 더 큰 값이 되기 때문이다.
delete – 계속 5
5
3
12
4
3
7
15
9
6
8
5
13
9
4
17
3
7
15
9
6
13
8
- 137 -
9
4
17
7
6
15
8
13
17
public boolean delete(Object item){ if(isEmpty()) throw new TreeUnderflowException(“…”); if(item==null) throw new NullPointerException(“…”); Comparable x = (Comparable)item; deleteSuccessful = false; root = delete(x, root); if(deleteSuccessful){ numOfNodes--; return true; } else return false; } private BSTNode delete(Comparable item, BSTNode node){ if(node == null) return node; int comp = item.compareTo(node.info); if(comp<0) node.left = delete(item, node.left); else if(comp>0) node.right = delete(item, node.right); else{ deleteSuccessful = true; node = deleteNode(node); } return node; }
private BSTNode deleteNode(BSTNode node){ if(node.left==null&&node.right==null) return null; // 단말노드 else if(node.right==null) return node.left; // 오른쪽 자식이 없는 노드 else if(node.left==null) return node.right; // 왼쪽 자식이 없는 노드 else{ // 자식이 모두 있는 노드 Object predecessor = getPredecessor(node.left); node.info = predecessor; node.left = delete((Comparable)predecessor, node.left); return node; } } private getPredecessor(BSTNode node){ … } 10
5
12
8
3
7
- 138 -
8
5
3
12
7
Traverse 세 가지 순회 방법을 제공하는 반복자 클래스 사용 지정된 방법으로 미리 순회하여 순회한 순서로 큐에 노드의 요소를 추가함
protected class TreeIterator implements Iterator{ LinkedQueue traverseQueue; public TreeIterator(int type){ traverseQueue = new LinkedQueue(); switch(type){ case INORDER: inOrder(root); return; case PREORDER: preOrder(root); return; case POSTORDER: postOrder(root); return; } // switch } public boolean hasNext() { return !traverseQueue.isEmpty(); } public Object next(){ return traverseQueue.deq(); } public void remove(){ return new UnsupportedOperationException(“…”); } private void preOrder(BSTNode node){ … } private void inOrder(BSTNode node){ if(node!=null){ if(node.left!=null) inOrder(node.left); traverseQueue.enq(node.info); if(node.right!=null) inOrder(node.right); } } private void postOrder(BSTNode node){ … } }
순회는 앞서 언급한 바와 같이 크게 세 가지 방법이 존재한다. 이진 검색 트리의 반복자 클 래스는 이들 세 가지 방법을 모두 제공하도록 구현한다. 순회를 구현하는 기본적인 원리는 다음과 같다. 먼저 지정된 방법으로 미리 순회를 한다. 이 때 순회한 순서대로 노드의 요소 들을 큐에 삽입한다. 사용자가 순회를 시작하면 트리를 순회하면서 노드를 방문하는 것이 아니라 이 큐에 삽입된 순서대로 큐를 통해 순회를 한다.
비교 BST*
배열 리스트
O(1)
O(N)
O(1)
search
O(log2N)
O(log2N)
O(N)
retrieve Find process Total
O(log2N) O(1) O(log2N)
O(log2N) O(1) O(log2N)
O(N) O(1) O(N)
insert Find process Total
O(log2N) O(1) O(log2N)
O(log2N) O(N) O(N)
O(N) O(1) O(N)
delete Find process Total
O(log2N) O(log2N) O(log2N)
O(log2N) O(N) O(N)
O(N) O(1) O(N)
constructor
연결 리스트
*. 균형 트리일 경우에만 O(log2N)
이진 검색 트리는 기존 배열을 이용한 리스트나 연결구조를 이용한 리스트와 달리 모든 연 산의 시간복잡도가 지수시간이다. 하지만 여기서 주의해야 하는 것은 이진 검색 트리가 균 형 트리일 경우에만 지수시간이고, 최악에 경우에는 연결구조를 이용한 리스트와 동일할 수 있다. 균형 트리란 왼쪽 서브트리의 높이와 오른쪽 서브트리의 높이의 차가 최대 하나인 트 리를 말한다.
- 139 -
트리의 균형 맞추기 기본적인 생각 단계 1. 트리 정보를 배열에 저장한 다음에 세 가지 방법: inorder, preorder, postorder 단계 2. 이 정보를 이용하여 트리를 재구성 아이템을 추가할 때마다 균형을 맞추기 위해 트리를 재구성하는 것은 비효율적이다. 그러면 언제? 트리의 현재 높이와 현재 노드의 수로 구성할 수 있는 최적의 트리의 높이의 차이가 너무 크면 기준은 응용에 따라 결정
이진 검색 트리는 모든 연산의 시간복잡도가 지수 시간이 될 수 있지만 트리의 균형이 유지 되는 경우에만 그렇다. 그런데 이진 검색 트리의 모습은 노드들이 추가된 순서에 의해 결정 되므로 시간이 경과되면 매우 편향된 이진 트리가 될 수 있다. 따라서 성능을 유지하기 위 해서는 트리의 균형을 종종 맞추어야 한다. 하지만 트리의 균형을 맞추는 비용이 매우 비싸 므로 노드를 추가할 때마다 균형을 맞출 수는 없다. 트리의 균형을 맞추는 한 가지 방법은 트리가 균형 트리가 되도록 삽입 순서를 변경하여 빈 트리부터 다시 모든 노드들을 하나씩 추가하는 것이다.
트리의 균형 맞추기 – 계속 3
10
5
12
8
3
7
10
5
5
7
12
8
3
8
3
7
5
8
12
7
10
10
inorder: 3 5 7 8 10 12 12
preorder: 10 5 3 8 7 12 postorder: 3 7 8 5 12 10
inorder
preorder
postorder
트리에 있는 모든 노드를 이용하여 트리를 다시 재구성하기 위해서는 먼저 트리에 있는 모 든 노드를 임시로 큐나 배열와 같은 구조에 저장을 해야 한다. 앞서 살펴본 세 가지 순회방 법으로 순회하면서 배열에 저장하거나 큐에 저장한 후에 그 순서대로 다시 트리를 재구성하 면 세 가지 순회 방법 모두 균형 트리를 생성해주지 못한다.
- 140 -
트리의 균형 맞추기 – 계속 7
8 10 5 5
3 8
3
12
5
10
12
7
7
10
8
3
12
방법: inorder에서 중간 노드부터 삽입 3 3
5 5
10 12
low=0, high=6, mid=3
7
8
7
low=0, high=2, mid=1
inorder: 3 5 7 8 10 12 3 7 10 12
low=4, high=5 Æ high, low순으로 삽입
트리가 균형 트리가 되기 위해서 트리에 있는 전체 노드들 중에 중간 값이 되는 노드가 트 리의 루트가 되어야 한다. 또한 중간 값을 기준으로 그 값보다 작은 값들 중에서 중간 값과 그 값보다 큰 값들 중에서 중간 값이 그 다음 레벨에 추가되어야 한다.
트리의 균형 맞추기 – 계속 public void BalanceTree(){ inorder로 Object 배열 nodes에 BST의 각 노드 값을 차례로 삽입 BalanceTree(0,numOfNodes,nodes); } private void BalanceTree(int low, int high, Object[] nodes){ low==high이면 nodes[low]를 삽입 (low+1)==high이면 nodes[high]와 nodes[low]를 모두 삽입 위 두 경우가 아니면 mid = (low+high)/2 nodes[mid]를 삽입 BalanceTree(low, mid-1, nodes); BalanceTree(mid+1, high, nodes); }
트리의 균형을 맞추기 위한 알고리즘은 다음과 같다. 중위 방법으로 트리를 순회하면서 순 회한 순서대로 임시 배열에 0번째 색인부터 차례대로 저장한다. 그 다음 트리를 빈 트리로 만들고 임시 배열로부터 하나씩 선택하여 트리를 재구성한다. 이 때 중간값을 가장 먼저 삽 입하고 중간값을 기준으로 배열을 이등분하여 배열의 크기 1 또는 2가 아닌 이상 다시 이 과정을 반복한다. 배열의 크기가 1이면 해당 노드를 추가하면 되고, 배열의 크기가 2인 경 우에는 큰 값부터 먼저 삽입한다. 이것은 트리를 완전 이진 트리로 만들기 위함이다.
- 141 -
제11장 힙, AVL 트리 이 장에서는 이진 트리의 특수한 형태인 힙(heap)과 AVL 트리에 대해 살펴본다.
11.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 이진 트리 구조의 유용한 자료 구조 우선순위 큐: 힙(heap) AVL 트리: 균형 트리
11.2. 우선순위 큐
우선순위 큐 일반적으로 큐는 FIFO 구조를 가진다. 우선순위 큐는 큐에 들어가는 순서와 상관없이 어떤 우선순위에 의해 나가는 순서가 결정되는 큐를 말한다. 우선순위는 응용에 따라 다르며, 들어오는 순서가 빠를 수록 높은 우선순위를 가질 경우에는 FIFO 큐도 우선순위 큐로 볼 수 있다. 우선순위 큐를 구현하는 방법 비정렬 리스트
정렬 리스트
이진 검색 트리
enq
O(1)
O(N)
O(log2N)*
deq
O(N)
O(1)
O(log2N)
모두 최악의 경우 비용이다. *. 평균 비용이다. 이진 검색 트리의 현재 모습에 따라 비용이 다를 수 있다.
6장에서 살펴본 바와 같이 일반적으로 큐는 FIFO 구조를 가진다. 하지만 큐에 삽입된 순서 에 의해 빠져나가는 순서를 결정하지 않고, 어떤 우선순위에 의해 나가는 순서를 결정할 수 있다. 이런 큐를 우선순위 큐(priority queue)라 한다. 우선순위 큐에서 우선순위는 응용에 따 라 다양하게 결정될 수 있으며, 6장에서 배운 일반 큐도 큐에 삽입된 순서에 의해 우선순위
- 143 -
가 결정되는 우선순위 큐의 한 종류로 볼 수도 있다. 우선순위 큐는 지금까지 배운 다양한 자료구조를 이용하여 구현될 수 있다. 각 구조를 이용하여 우선순위 큐를 구현하였을 때 비 용을 비교하여 보자. 우선 비정렬 리스트를 이용할 경우에는 enqueue는 O(1)이지만
dequeue는 현재 큐에 저장되는 있는 모든 요소를 서로 비교하여 가장 큰 값을 찾아야 하므 로 O( )이다. 반대로 정렬 리스트를 이용할 경우에는 enqueue할 때 우선순위에 따라 정렬 이 되도록 저장해야 하므로 O( )이지만 dequeue의 비용은 O(1)이다. 이진 검색 트리를 이 용하여 우선순위 큐를 구현하면 enqueue와 dequeue 모두 평균적으로 O( )이다. 하지 만 이진 검색 트리는 삽입되는 순서에 의해 트리의 모습이 결정되므로 최악의 경우에는 선 형 리스트와 차이가 없을 수 있다. 다음 절에서 배울 힙은 이런 단점이 없는 이진 트리 형 태의 자료구조이다.
11.3. 힙
힙 힙(heap)은 이진 트리를 이용하여 우선순위 큐를 구현하는 자료구조로서, 다음 두 가지 특성을 만족해야 한다. 모양 특성: 특성 항상 완전 이진 트리를 유지해야 한다. 순서 특성: 특성 각 노드의 값은 그것의 자식 노드들의 값보다는 크거나 같다. 따라서 항상 루트 노드가 가장 큰 값을 가지게 된다. J
J heap
H
I
D
B
G
C
E
F
I
A
H
F
B
G
C
E
A
D
힙(heap)은 우선순위 큐를 구현할 때 널리 사용되는 자료구조로서 다음 두 가지 특성을 만 족하는 이진 트리 구조이다. 첫째, 항상 완전 이진 트리를 유지해야 한다. 둘째, 각 노드의 값은 그것의 자식 노드들의 값보다는 크거나 같아야 한다. 첫 번째 특성을 모양 특성이라 하고, 두 번째 특성을 순서 특성이라 한다. 순서 특성 때문 에 루트 노드가 항상 가장 큰 값을 가지게 되며, 모양 특성 때문에 트리의 높이는 항상 최 소 높이가 된다. 또한 힙은 이진 검색 트리와 달리 삽입되는 순서와 상관없이 트리의 모습 은 일정하다. 다만, 노드들의 값은 삽입되는 순서에 따라 다를 수 있다.
- 144 -
힙: dequeue 힙에서 dequeue는 항상 루트를 제거하게 된다. 쉽게 제거할 수 있지만 힙은 항상 완전 이진 트리를 유지해야 하므로 완전 이진 트리가 되도록 트리를 재조정해야 한다. 트리가 완전 이진 트리가 유지되도록 하기 위해서는 가장 마지막 레벨에 있는 단말 노드들 중에서 가장 오른쪽에 있는 노드를 루트로 옮겨야 한다. J
D
I
H
F
B
G
C
I
E
A
H
F
D
B
G
E
A
C
힙: dequeue – 계속 이 경우 모양 특성은 만족되지만 순서 특성을 만족하지 못한다. 순서 특성이 만족되도록 자식 노드 값과 교체해야 하며, 이런 교체는 트리가 순서 특성을 만족할 때까지 반복된다. 이런 재조정을 reheapdown이라 한다. reheapdown D
I
I
F
B
H
G
C
E
G
A
F
B
H
D
E
A
C
힙은 특성상 루트 노드에 가장 큰 값이 위치하게 되므로 dequeue는 루트 노드를 제거하면 된다. 힙은 모양 특성과 순서 특성을 유지해야 하므로 루트 노드를 제거하는 것이 단순하지 는 않다. 우선 모양 특성을 유지하기 위해서는 가장 마지막 레벨에 있는 단말노드 중에서 가장 오른쪽에 있는 노드를 루트 노드로 옮긴다. 이렇게 옮기면 모양 특성은 유지되지만 순 서 특성은 만족되지 못한다. 순서 특성을 만족시키기 위해 자식 노드들과 값을 비교하여 자 식 노드와 자리를 바꾼다. 이런 바꿈은 순서 특성이 만족될 때까지 반복적으로 이루어진다. 이 과정을 reheapdown이라 한다. 마지막 레벨에 가장 오른쪽에 있는 단말노드를 루트로 옮 기는 비용이 상수 비용이면 힙의 높이는 항상 이므로 힙에서 dequeue의 시간복잡도 는 로그 시간이다.
- 145 -
힙: enqueue 힙에서 enqueue는 완전 이진 트리를 유지하기 위해서는 항상 가장 마지막 레벨에 가장 오른쪽에 추가되어야 한다. 이렇게 추가하면 모양 특성은 만족되나 삽입되는 값에 따라 순서 특성은 만족되지 않을 수 있다. P
P
L
N
E
B
F
C
K
L
G
A
N
E
B
F
C
A
K
G
M
힙: enqueue – 계속 dequeue와 비슷하게 순서 특성을 만족하도록 부모와 값을 교체해야 한다. 이런 교체는 트리가 순서 특성을 만족할 때까지 반복되어야 하며, 이런 재조정을 reheapup이라 한다. reheapup
P
P
L
N
E
B
F
C
A
K
M
M
G
N
E
B
L
C
A
K
G
F
힙에서 enqueue는 모양 순서 특성을 만족하기 위해 마지막 레벨의 가장 오른쪽 단말노드로 새 데이터가 추가되어야 한다. 하지만 dequeue와 마찬가지로 이렇게 함으로써 모양 특성을 만족할 수는 있지만 순서 특성은 만족하지 못한다. 이를 위해 추가된 노드를 부모 노드와 비교하여 추가된 노드가 부모 노드보다 크면 서로 자리를 바꾼다. 이런 바꿈은 순서 특성을 만족될 때까지 반복적으로 이루어진다. 이 과정을 reheapup이라 한다. 따라서 마지막 레벨 의 가장 오른쪽 단말노드로 노드를 추가하는 비용이 상수 비용이면 enqueue의 시간복잡도 역시 로그시간이다.
- 146 -
힙의 구현 힙을 구현할 때 트리의 마지막 레벨에 가장 오른쪽 단말노드를 쉽게 접근할 수 있어야 하며, 노드에서 그 노드의 자식 노드를 접근할 수 있을 뿐만 아니라 부모 노드를 접근할 수 있어야 한다. 연결구조를 이용하여 구현하고자 하면 노드를 나타내는 정보에 부모에 대한 연결을 추가해야 한다. 힙은 완전 이진 트리를 항상 유지하므로 이렇게 연결구조로 구현하는 것보다 배열로 구현하는 것이 효과적이다. 배열을 이용한 구현에서는 부모와 자식에 대한 연결을 명백하게 유지하지 않는다. 대신에 노드가 저장되어 있는 배열의 색인을 이용하여 부모와 자식 노드가 저장되어 있는 배열의 색인을 계산할 수 있다.
enqueue와 dequeue의 설명에서 알 수 있듯이 이들 연산의 시간복잡도가 로그시간이 되기 위해서는 트리의 가장 마지막 레벨의 가장 오른쪽 단말노드로 새 노드를 추가하는 비용이 로그시간을 넘지 않아야 한다. 또한 reheapup을 구현하기 위해서는 노드에서 그것의 자식 노드를 접근할 수 있을 뿐만 아니라 노드에서 그것의 부모 노드를 접근할 수 있어야 한다. 따라서 10장에서 사용된 연결구조 방식의 이진 트리는 힙을 구현하기에 적합하지 않다. 물 론 노드를 나타내는 정보에 부모에 대한 연결을 추가하면 되지만 노드가 차지하는 공간이 커지며 트리를 유지하는 비용이 증가한다. 그런데 힙은 항상 완전 이진 트리를 유지하므로 배열을 이용하여 보다 효율적으로 나타낼 수 있다.
0
P
L
E
B
N
F
C
K
G
P
1
L
2
N
3
E
4
F
5
K
6
G
7
B
8
C
부모의 위치: (index-1)/2 왼쪽 자식의 위치: (index*2)+1 오른쪽 자식의 위치: (index*2)+2
배열을 이용한 이진 트리의 표현은 한 노드에서 자식 노드들은 물론 부모 노드도 쉽게 접근할 수 있다. 배열을 이용한 완전 이진 트리의 표현은 중간에 사용되지 않는 항이 없다. 마지막 레벨의 가장 오른쪽 단말 노드의 위치는 트리에 있는 노드의 수를 통해 얻을 수 있다.
배열을 이용한 이진 트리를 표현할 때 루트 노드는 색인이 0인 항에 위치하며, 그것의 왼쪽 자식은 색인이 1인 항에 위치하고, 오른쪽 자식은 색인이 2인 항에 위치한다. 즉, 한 노드가 색인이 i인 항에 위치하면 그것의 부모는 색인이 (i-1)/2인 항에 위치하며, 그것의 왼쪽 자식 노드는 색인이 (i*2)+1인 항에, 오른쪽 자식 노드는 색인이 (i*2)+2인 노드에 위치한다. 이렇 게 배열을 이용하여 이진 트리를 표현하면 한 노드에서 자식 노드는 물론 부모 노드까지 쉽
- 147 -
게 접근할 수 있다. 더욱이 완전 이진 트리를 배열을 이용하여 표현하면 중간에 빈 항들이 존재하지 않게 된다. 또한 트리의 마지막 레벨의 가장 오른쪽 단말노드의 위치는 트리에 있 는 노드의 수를 통해 쉽게 알 수 있다.
힙의 구현: PriorityQueue, Heap
public interface PriorityQueue{ boolean isEmpty(); boolean isFull(); void enq(Object item); Object deq(); }
public class Heap implements PriorityQueue{ private static final int MAX_HEAP_SIZE = 50; private Object[] elements; private int lastIndex = -1; public Heap(){ setup(MAX_HEAP_SIZE); } public Heap(int capacity){ if(capacity>0) setup(capacity); else setup(MAX_HEAP_SIZE); } private void setup(int capacity){ elements = new Object[capacity]; } public boolean isEmpty(){ return (lastIndex == -1); } public boolean isFull(){ return (lastIndex == elements.length-1); } … }
Enqueue public void enq(Object item) throws QueueOverflowException{ if(isFull()) throw new QueueOverflowException(“…”); if(item==null) throw new NullPointerException(“…”); Comparable o = (Comparable)item; lastIndex++; reheapUp(o); } private void reheapUp(Comparable item){ int hole = lastIndex; int parent = (hole-1)/2; while((hole>0) && (item.compareTo(elements[parent])>0)){ elements[hole] = elements[parent]; hole = parent; parent = (hole-1)/2; } L elements[hole] = item; }
P
N
E
B
F
C
A
K
hole
G
M
item
enqueue는 앞서 설명한 바와 같이 우선 모양 특성을 만족하기 위해 새 노드를 트리의 가장 마지막 레벨의 가장 오른쪽 단말노드가 되도록 추가해야 한다. 배열을 이용한 이진 트리 구 현에서 마지막 레벨의 가장 오른쪽 단말노드의 위치는 트리의 있는 노드의 개수를 통해 알 수 있다. 이 구현에서는 lastIndex 멤버변수가 이 정보를 유지하고 있다. 우선 이 값을 하나 증가시키고, 이 위치에 빈 노드를 삽입한다. 그 다음에 reheapup 과정을 수행하여 순서 특 성이 만족되도록 트리를 재조정해야 한다. 부모 노드와 비교하였을 때 부모 노드보다 추가 할 값이 작으면 빈 노드에 값을 추가하는 것으로 enqueue가 완료된다. 하지만 부모 노드보 다 추가할 값이 크면 빈 노드에 부모 노드의 값을 옮기고 부모 노드를 빈 노드로 만든다. 이 과정을 순서 특성이 만족될 때까지 반복한다.
- 148 -
Dequeue public Object deq() throws QueueUnderflowException{ if(isEmpty()) throw new QueueUnderflowException(“…”); Comparable toMove = (Comparable)elements[lastIndex]; Object tmp = elements[0]; lastIndex--; reheapDown(toMove); return tmp; } private void reheapDown(Comparable item){ int hole = 0; int nextHole; nextHole = findNextHole(hole, item); while(nextHole != hole){ elements[hole] = elements[nextHole]; hole = nextHole; nextHole = findNextHole(hole, item); } elements[hole] = item; }
Dequeue
hole nextHole M
N
E
B
L
C
A
K
G
F
private int findNextHole(int hole, Comparable item){ int left = hole*2+1; int right = hole*2+2; Comparable leftChild = null; // hole이 자식이 없는 경우 if(left>lastIndex){ return hole; } // hole이 왼쪽 자식밖에는 없는 경우 else if(left==lastIndex){ if(item.compareTo(elements[left])>0) return hole; else return left; } // hole이 왼쪽, 오른쪽 자식이 모두 있는 경우 else{ leftChild = (Comparable)elements[left]; if(leftChild.compareTo(elements[right])>0){ if(item.compareTo(leftChild)>0) return hole; else return left; } else{ if(item.compareTo(elements[right])>0) return hole; else return right; } } }
dequeue는 enqueue보다 조금 더 복잡하다. 우선 lastIndex 정보를 이용하여 트리의 가장 마지막 레벨의 가장 오른쪽 단말노드의 값을 루트 노드로 옮기고 이 노드를 삭제한다. 이 삭제는 lastIndex 값을 하나 감소하면 된다. 그 다음 순서 특성이 만족되도록 reheapdown을 해야 한다. reheapup은 한쪽 방향으로 최대 루트까지 올라가면 되지만 reheapdown은 현재 노드가 두 개의 자식 노드을 가질 수 있으므로 각 자식들과 비교하여 이 중에 보다 큰 값과 교체되어야 한다. 어떤 자식과 교체할지는 findNextHole 메소드를 통해 결정한다.
- 149 -
힙 VS. 다른 우선순위 큐 구현 이진 검색 트리
힙
정렬리스트 (연결구조)
균형
편향
enq
O(log2N)
O(N)
O(log2N)
O(N)
deq
O(log2N)
O(1)
O(log2N)
O(N)
이진 검색 트리가 균형트리일 경우에는 힙과 성능이 같다. 이진 검색 트리는 삽입되는 순서에 따라 최악의 경우에는 편향 트리가 될 수 있다. 하지만 힙은 이진 검색 트리와 달리 항상 비용이 일정하다.
힙을 이용한 우선순위 큐는 요소들이 큐에 삽입된 순서와 상관없이 항상 완전 이진 트리를 유지하므로 enqueue와 dequeue 연산의 시간복잡도는 로그 시간이다.
11.4. AVL 트리 이진 검색 트리는 노드들이 삽입된 순서에 의해 트리의 모습이 결정되므로 매우 한쪽 방향 으로 편향될 수 있다. 이것이 이진 검색 트리의 가장 큰 단점이다. 이 단점을 극복하기 위 해 트리의 균형을 주기적으로 맞출 수 있지만 균형을 맞추는 비용이 매우 비싸다. AVL 트 리는 이런 단점을 극복할 수 있는 이진 검색 트리이다.
AVL 트리(균형트리) AVL(AdelsonAVL(Adelson-Velskii and Landis) 트리: 트리 각 노드의 왼쪽 서브트리의 높이와 오른쪽 서브트리의 높이의 차이가 1이하인 이진 검색 트리 균형을 저렴한 비용으로 항상 유지할 수 있다면 기본 이진 검색 트리의 문제점을 해소할 수 있다. 이진 트리의 한 노드가 AVL 성질을 만족하는지 검사하는 방법 가장 긴 왼쪽 경로의 길이와 가장 긴 오른쪽 경로의 길이를 비교 두 경로의 차이를 균형 인수(balance factor)라 한다. 인수 2
60
45
1
63
60
45
0 41
55
61
41 non AVL Tree
AVL Tree
AVL 트리는 각 노드의 왼쪽 서브트리의 높이와 오른쪽 서브트리의 높이의 차이가 1이하인 이진 검색 트리를 말하며, 러시아 수학자 Adelson-Velskii와 Landis에 의해 고안되었다. 이진 트리의 한 노드가 AVL 성질을 만족하는지 검사하기 위해서는 그 노드의 왼쪽 서브 트리의 단말노드까지 가장 긴 경로의 길이와 그 노드의 오른쪽 서브 트리의 단말노드까지 가장 긴
- 150 -
경로의 길이를 비교해야 한다. 이 두 경로의 차이를 균형인수(balance factor)라 하며, 이 값 이 -1에서 1 사이의 값이어야 한다. 따라서 AVL 트리의 높이는 이진 검색 트리의 최소 높 이와 항상 일치하지는 않지만 최소 높이와 거의 근접한 높이로 유지된다.
AVL 트리: 삽입 삽입은 이진 검색 트리에서 삽입과 같다. 그러나 삽입이 AVL 트리의 성질을 만족하지 못하면 트리의 균형이 깨질 수 있다. 핵심. 루트에서 삽입된 위치까지의 경로에 있는 노드들만 균형 인수가 변경될 수 있다. 이 노드들의 부분 트리만 변경된다. 재균형은 삽입된 위치부터 루트까지의 경로를 따라 올라가면서 균형 인수를 갱신한다. 갱신하는 도중에 AVL 성질을 위배하는 노드를 만나면 이 노드의 부분트리를 재조정한다. 한번 재조정을 하면 그것으로 트리는 다시 AVL 성질을 만족한다. 이것은 삽입되기 전에 트리는 AVL 성질을 만족하고 있었고, 삽입된 후에 노드들의 균형 인수 값은 최대 하나 증가하거나 하나 감소할 수 있기 때문이다.
AVL 트리에서 삽입은 이진 검색 트리에서 삽입과 같다. 그러나 이렇게 삽입을 하였을 때 AVL 트리의 성질이 계속 유지되지 않을 수 있다. 이 경우에는 트리를 재조정해야 한다. 이 진 검색 트리를 설명할 때 기술된 이진 검색 트리를 전체적으로 재조정해서 완전 이진 트리 를 만드는 방법을 사용해서 재조정한다는 것은 아니다. 여기서의 재조정은 부분적으로만 저렴한 비용으로 재조정하여 완전 이진 트리를 만들지는 않지만 완전 이진 트리와 가까운 트리를 만들어 나중에 검색할 때 효율성을 높이겠다는 것을 말한다. 한 노드가 삽입되었을 때 그 노드부터 루트 노드까지의 경로 상에 있는 노드들만 균형인수 가 변경될 수 있다. 이것은 이들만 자신들의 부분 트리가 변경되기 때문이다. 균형을 다시 맞추기 위해서는 삽입한 노드부터 루트까지의 경로를 따라 올라가면서 균형인수를 갱신한 다. 갱신 도중에 AVL 성질을 위배하는 노드를 만나면 이 노드의 부분 트리를 재조정한다. 이렇게 한번 재조정을 하게 되면 트리는 다시 AVL 성질을 만족하게 된다. 이것은 삽입되기 전에 트리가 AVL 성질을 만족하고 있었으므로 한 노드가 삽입되면 다른 노드들의 균형인수 값은 최대 하나 증가되거나 하나 감소된다. 따라서 삽입된 노드로부터 루트까지 올라가면서 처음 균형이 위배된 노드를 재조정하면 나머지 노드들의 균형인수는 삽입 전과 같아진다.
- 151 -
AVL 트리: 삽입 – 계속 부분 트리를 재조정해야 하는 노드 x를 기준으로 삽입된 노드의 위치는 다음 네 가지로 분류할 수 있다. LL: x의 왼쪽 자식의 왼쪽 부분 트리에 삽입된 경우 RR: x의 오른쪽 자식의 오른쪽 부분 트리에 삽입된 경우 LR: x의 왼쪽 자식의 오른쪽 부분 트리에 삽입된 경우 RL: x의 오른쪽 자식의 왼쪽 부분 트리에 삽입된 경우 2
2
-2 A
A -1
1 B C
RR
A 1
-1 B
B
LL
-2 A
C
B C
C RL
LR
트리의 균형을 맞추기 위해 부분 트리를 재조정해야 노드를 기준으로 삽입된 노드의 위치는 크게 LL, RR, LR, RL 네 가지 경우로 분류할 수 있다.
LL, RR 회전 트리의 균형은 노드들을 회전(rotate)하여 맞춘다. 회전 LL과 RR의 경우에는 단일 회전으로 균형을 맞출 수 있지만 LR과 RL의 경우에는 이중 회전을 해야 균형을 맞출 수 있다. 2
0
A
A
1
-1
A
T3 T1
T2
T1
B 0
0 B
0
-2
B
B
A
T1 T2
T3
LL Rotate
T2
T3
T1
T2
T3
RR Rotate
트리의 군형은 노드들을 회전하여 맞춘다. 삽입된 노드의 위치와 그것의 조상 노드 중에 처 음으로 균형인수가 -2 또는 2가 된 노드의 위치에 따라 LL, RR, LR, RL 회전을 한다. 삽입 된 노드의 조상 노드들 중에 삽입에 의해 처음으로 균형인수가 -2 또는 2가 된 노드를 회전 의 기준 노드라 한다. 이 때 LL과 RR은 단일 회전으로 균형을 맞출 수 있지만 LR과 RL은 두 번 회전이 필요하므로 이중 회전이 필요하다고 말한다. LL과 RR은 기준 노드의 자식 중 새로 삽입된 노드의 조상 노드가 되는 노드를 기준 노드 자리로 끌어올리고 기준 노드는 반 대 방향으로 내린다.
- 152 -
LR 회전 2
2
A
2 A
A
-1
-1 B
-1 B
1
B -1 C
C
C T4 T1
T2
T3
0
0
0
C
C 0
0 B
T4 T1
T3
T2
C
0
A
1
-1 B
T1
A
T3
T2
0 B
T4
A
T2
T1
T3
T4
LR 회전은 처음으로 균형인수가 2가 된 노드를 포함하여 그 노드부터 삽입된 노드까지 경 로에 있는 가장 가까운 두 개의 후손 노드들이 회전에 참여하게 된다. 기준 노드를 A 노드 라 하고 두 개의 후손 노드들을 차례대로 B와 C 노드라 하면, 이 부분 트리를 재조정하였 을 때 루트는 C가 되고, C의 왼쪽 자식은 B, 오른쪽 자식은 A 노드가 된다. 또한 C 노드의 왼쪽 부분 트리는 B 노드의 오른쪽 부분 트리가 되고, C의 오른쪽 부분 트리 A의 왼쪽 부 분트리가 된다.
RL 회전 -2
-2
-2
A
A
A
1 1
1
B
B
-1
B 1
C
C
C T1
T1 T2
T4 T3
T2 0
0
0
C
C 0
0 A
T4
T3
C
1
B
0 A
T1
0
B
T2
T3
-1 A
T4
T1
B
T2
T3
T4
RL 회전은 LR 회전과 마찬가지로 처음으로 균형인수가 -2가 된 노드를 포함하여 그 노드부 터 삽입된 노드까지 경로에 있는 가장 가까운 두 개의 후손 노드들이 회전에 참여하게 된 다. 기준 노드를 A 노드라 하고 두 개의 후손 노드들을 차례대로 B와 C 노드라 하면, 이 부분 트리를 재조정하였을 때 이 회전 역시 C가 루트가 되고, C의 왼쪽 자식은 A, 오른쪽 자식은 B 노드가 된다. 또한 C 노드의 왼쪽 부분트리는 A 노드의 오른쪽 부분 트리가 되 고, C의 오른쪽 부분 트리 B의 왼쪽 부분 트리가 된다.
- 153 -
AVL 트리를 구현할 때 노드를 삽입한 후에 루트 노드까지 거슬러 올라가면서 균형인수 값 을 조정해야 한다. 따라서 부모노드에 대한 연결이 필요하다. 이를 위해 부모노드에 대한 연결을 노드를 나타내는 정보에 추가할 수 있지만 유지해야 하는 정보가 증가되므로 각종 연산들이 복잡해지는 문제점이 있다. 이것을 극복하는 한 가지 방법은 삽입을 하기 위해 단 말노드를 찾아갈 때 스택에 방문한 노드들을 push하는 것이다. 이렇게 하면 스택의 top에는 항상 현재 노드의 부모 노드가 저장되어 있게 된다.
AVL 트리: 삽입 예 -1
1
15
0
15
-2
2
2
10
45
7
0
10
1
45
15
30
45
30
LR
2 15
5
1 15
2
30
30
15
1 0
5
0
LL
2
0
0 45
5
0
30 0
0 45
5
0 -1
15
-1
45
5
RR
15
30
1
0 30 15
2
30
0
5 5
0 15
2
2 2
30
30
10
10
45
45
45 7
12
AVL 트리의 삽입 연산을 구현할 때 주의해야 하는 점은 다음과 같다. 첫째, 삽입된 단말노드의 부모노드의 균형인수는 항상 변한다. 둘째, 삽입된 노드부터 루트노드까지 올라가면 균형인수를 변경할 때 어떤 노드의 균현 인 수 값이 변경되어 0이 되면 그 이후의 조상노드들의 균형인수는 변하지 않는다. 위 예제에 서 12를 삽입하였을 때 10 노드의 균형인수 값은 1에서 0을 변경된다. 하지만 그 이후 조 상 노드들은 균형인수는 변하지 않는다.
AVL 트리: 삽입 예 – 계속 2 1
15
10 0
15
-2 5
RL
30
0 15
1
0
7 7
30
1 2
10
45
5 5
-1
10
8
12
30
45 15 2
7
12
14
2 2
8
12
-1 7
30
8
-1 5
2
10
8
45
12
14
- 154 -
LR
45
AVL 트리: 삭제 삭제도 일반 이진 검색 트리에서 삭제를 먼저 수행한다. 삭제도 삽입과 마찬가지로 AVL 트리의 성질을 위배할 수 있다. 삽입의 경우에는 첫 번째 위배된 노드를 기준으로 재조정하면 전체 트리의 균형이 이루어지지만 삭제의 경우는 한번 재조정을 하여도 여전히 트리가 AVL 트리의 성질을 위배할 수 있다. 따라서 루트까지 계속 검사하면서 여러 번 재조정해야 하는 경우가 존재한다. 단말노드가 삭제될 삭제 경우에는 삭제된 노드의 부모노드부터 루트까지의 경로에 있는 노드들만 균형 여부를 검사한다. 자식이 하나인 중간노드가 삭제될 삭제 경우에는 삭제된 노드의 부모노도부터 루트까지 균형 여부를 검사한다. 자식이 둘인 중간 노드가 삭제될 삭제 경우에는 중간노드와 대체되는 노드의 부모노드부터 루트까지 균형 여부를 검사한다.
AVL 트리에서 삭제도 삽입과 마찬가지로 먼저 이진 검색 트리에서 삭제를 수행한다. 그 다 음에 삭제된 노드의 부모노드로부터 루트노드까지 경로에 있는 균형인수를 검토하여 균형이 위배되었으면 트리를 재조정한다. 자식의 둘인 중간노드의 삭제의 경우에는 실제 삭제는 단 말에서 이루어지므로 이 노드의 부모노드부터 균현 인수를 검토해야 한다. 삭제는 삽입과 달리 한번 재조정을 하는 것으로 완료되지 않을 수 있다. 즉, 균형의 위배되어 균형을 재조 정하면 그 노드가 루트가 되는 부분트리의 높이가 하나 감소하며, 이 감소는 부모노드의 균 형인수에 영향을 준다. 따라서 루트 노드까지 올라가면서 트리를 여러 번 재조정해야 하는 경우가 발생할 수 있다.
AVL 트리: 삭제 – 고려사항1 삭제된 노드의 부모노드부터 루트 노드까지 거슬러 올라가면서 균형인수를 재조정할 때 다음을 고려한다. 경우 1. 노드의 균형인수가 0으로 바뀌면 그것의 부모노드의 균형인수도 변경되어야 한다. (바뀌기 전 인수 값은 1 또는 -1) 경우 2. 노드의 균형인수가 1 또는 -1로 바뀌면 그것의 부모노드의 균형인수는 변경되지 않는다. (바뀌기 전 인수 값은 0) 경우 3. 노드의 균형인수가 2 또는 -2로 바뀌면 그 위치에서 재조정 해야 하며, 재조정되면 그 노드의 균형인수는 0이 된다. 따라서 경우 1과 마찬가지로 부모노드의 균형인수도 변경되어야 한다. 0 1 0
-1
경우 1.
0
0
경우 2.
0
0 0
1 0
0
삭제된 노드(자식의 둘인 중간노드는 실제 삭제된 단말노드)의 부모노드부터 루트노드까지 거슬러 올라가면서 균형인수를 재조정할 때 삽입과 마찬가지로 반드시 루트까지 계속 검토 해야 하는 것은 아니다. 삭제는 중간노드가 삭제될 수 있지만 이 역시 실제로는 단말노드가 삭제되는 것으로 볼 수 있다. 따라서 삭제된 단말노드의 부모노드는 반드시 그것의 균형인 수가 바뀐다. 하지만 조상노드들은 항상 변하는 것은 아니다. 노드의 균형인수가 삭제 때문
- 155 -
에 0으로 바뀌면 이것은 그 노드를 기준으로 트리의 높이가 하나 감소했다는 것을 의미한 다. 따라서 그것의 부모노드의 균형인수도 바뀌어야 한다. 하지만 노드의 균형인수가 삭제 때문에 1 또는 -1로 바뀌었다면 그것의 부모노드의 균형인수는 변하지 않는다. 즉, 삭제 전 에는 트리가 AVL 트리의 성질을 만족하고 있으므로 트리에 있는 모든 노드의 균형인수는
0, 1, 또는 -1이다. 노드의 균형인수가 1 또는 -1로 바뀌었다는 것은 그 전에 균형인수가 0 이었다는 것을 말하여, 이 경우에는 이 노드가 루트가 되는 부분트리의 높이는 변하가 없다 는 것을 말한다. 부모노드의 균형인수가 변하지 않으면 그것의 조상노드의 균형인수는 당연 히 변하지 않는다. 그러므로 여기서 삭제에 의한 트리의 재조정은 중단할 수 있다.
AVL 트리: 삭제 – 고려사항2 삽입과 전혀 다른 형태 존재 1
2
15
15 0
0 10 0
-1 10
LL
1
0
0 10
20
5
0 12
5
-1
-2
10
5 0 15
0 15
0
-1 10
20 0 25
20
RR 0
20
12 1
10 0
0
0
12
5
15
0 25
25 0 15
AVL 트리에서 삭제는 기존 삽입에서 보았던 LL, LR, RR, RL 네 가지 외에 특이한 두 가지 경우가 존재한다. 보통 삽입에서 LL은 A 노드의 균형인수가 2이고, B 노드의 균형인수가 1 이다. 하지만 삭제의 경우 이 슬라이드에서 보여주듯이 A 노드의 균형인수가 2이고, B 노드 의 균형인수가 0인 경우가 존재한다. 하지만 이 경우도 LL 회전을 통해 균형을 맞출 수 있 다. 또한 삽입에서 검토되었던 RR은 A 노드의 균형인수가 -2이고, B 노드의 균형인수가 -1 이다. 하지만 삭제의 경우 이 슬라이드에서 보여주듯이 A 노드의 균형인수가 -2이고, B 노 드의 균형인수가 0인 경우가 존재한다. 이 경우도 RL 회전을 통해 균형을 맞출 수 있다.
- 156 -
AVL 트리: 삭제 예 -1 -1
0
30
8
5
10
17
50
25
7
20
10
25
17
60
40
7
50
20
8
50
65
55
15
30
60
40
0
-1 8
20
30
RR
15
-1 7
0
-2
RR
15
10
17
40
25
55
60
65
65
55
삭제의 경우, 삭제된 노드의 부모노드부터 루트까지 균형인수를 검토하게 되는데, 이 경로에 있는 노드들이 회전에 사용되는 B노드, C노드가 되지 않는다. (삽입과 차이점)
단말노드의 삭제 키 값이 5인 노드의 삭제로 균형을 맞추기 위해 두 번의 회전이 필요하다.
이 예는 단말노드가 삭제되는 경우의 예이다. 단말노드 5가 삭제되면 7 노드의 균형인수 값 이 -2가 된다. 따라서 이 노드를 기준 노드로 트리를 재조정해야 한다. 이 때 이 노드가 A 노드가 되며, 삭제된 노드의 반대쪽 방향의 노드들이 B 노드와 C 노드가 된다. 즉, A 노드 를 기준으로 삭제된 노드가 A 노드의 왼쪽 부분트리에 있었으면 A 노드의 오른쪽 부분트리 에 있는 노드들이 B 노드와 C 노드가 된다. 이 점과 여러 번 재조정해야 한다는 것이 삽입 과 가장 큰 차이점이다. 위 예에서 7를 기준으로 재조정한 결과 그 부분 트리의 부모 노드 인 15 노드의 균형인수가 -2가 되었다. 따라서 이 노드를 기준으로 다시 재조정을 해야 한 다. 즉, 5의 삭제로 총 두 번의 재조정이 발생하였다.
AVL 트리: 삭제 예 – 계속 -1
-1
15
15
-1
-1 7
-1
30
7
25 1
5
8
20
10
17
50
25
40
55
5
60
-1 20
8
10
17
65
50
40
55
60
65
자식이 둘인 중간 노드의 삭제 이 경우에는 전혀 회전이 필요없다.
이 예는 자식이 둘인 중간노드를 삭제하는 예이다. 이진 검색 트리에서 자식이 둘인 중간노 드의 삭제는 그것의 왼쪽 부분 트리에서 가장 큰 값으로 대체하여 삭제를 한다. 따라서 이 경우에는 중간 노드의 삭제로 보기 보다는 25 노드의 삭제로 생각하여 처리한다.
- 157 -
AVL 트리: 삭제 예 – 계속 -2
-1 10
15
7
10
25
17
5
50
40
55
10
1
-1 20
8
-1 7
25 1
5
25
0
-1
20
8
60
50
-1 50
17
60
40
65
7
55
5
20
8
17
40
55
60
65
65
이 예는 역시 자식이 둘인 중간노드를 삭제하는 예이다. 하지만 이 경우는 앞 예제와 달리 회전이 필요하다.
AVL 트리 분석 AVL 트리
이진 검색 트리
찾기: T(N) = O(log2N) 처리: T(N) = O(1) 삽입 균형: W(N) = O(log2N) 전체: W(N) = O(log2N)
전체: W(N) = O(N)
찾기: T(N) = O(log2N) 처리: W(N) = O(log2N) 삭제 균형: W(N) = O(log2N 전체: W(N) = O(log2N)
전체: W(N) = O(N)
검색 전체: T(N) = O(log2N)
전체: W(N) = O(N)
회전 비용은 상수 시간이며, 최악의 경우 트리 높이만큼 회전할 수도 있다. 그러나 이것의 전체 비용은 트리 높이에 비례한다. 즉, AVL 트리는 일반 이진 검색 트리와 달리 삽입과 삭제의 연산 비용도 저렴하면서 검색은 항상 O(log2N)인 트리이다.
- 158 -
제12장 그래프 이 장에서는 그래프라는 자료구조에 대해 살펴본다.
12.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 개요 그래프는 트리를 포함하고 있는 보다 큰 개념의 자료구조로서, 노드와 또 다른 노드 간에 여러 경로가 존재할 수 있고, 간선의 방향이 존재할 수 있다. 비가중치 그래프 그래프 탐색: 너비 우선, 깊이 우선 신장 트리 가중치 그래프 최단 경로 알고리즘
12.2. 그래프
그래프 그래프(graph): 공집합이 아닌 정점(vertex)들의 유한 집합과 그래프 정점 이 정점들을 연결하는 간선(edge)으로 구성되는 자료구조 간선 수학적 정의: 그래프 G는 G = (V, E)로 정의되며, 여기서 V(G): 공집합이 아닌 정점들의 유한집합 E(G): 간선들의 집합 무방향 그래프(undirected graph): 간선의 방향성이 없는 그래프 그래프 유방향 그래프(directed graph, digraph): 간선의 방향성이 있는 그래프 그래프 방향 그래프에서는 간선을 아크(arc)라고도 한다. 아크 인접 정점(adjacent vertex): 간선에 의해 연결되어 있는 정점 정점 경로(path): 두 개의 정점을 연결하는 일련의 정점들 경로 정점 a에서 b까지의 경로 a, v1, v2, …, vn, b가 존재하기 위해서는 간선 (a, v1), (v1, v2), …, (vn-1, vn), (vn, b)가 존재해야 한다.
공집합이 아닌 정점(vertex)들의 유한집한과 이 정점들을 연결하는 간선(edge)으로 구성되는 자료구조를 그래프(graph)라 한다. 따라서 10장, 11장에서 살펴본 트리 구조는 그래프에 속 한다. 즉, 트리는 그래프의 특수한 형태이다. 그래프는 간선의 방향성이 있는지에 따라 무방 향(undirected) 또는 유방향(directed) 그래프로 나뉘어 진다. 방향성이 있는 간선을 다른 말
- 159 -
로 아크(arc)라 한다. 인접정점(adjacent vertex)이란 간선에 의해 연결되는 정점을 말하며, 경로(path)란 두 개의 정점을 연결하는 일련의 정점들을 말한다.
그래프 – 계속 M
A
V(G1)= {A,B,E,L,M}
E B
D
E(G1)= {(A,E),(B,L),(E,A) (L,L),(L,A),(M,B),(M,E)} B
C V(G1)={A,B,C,D} A
L
Path(M,A): M, B, L, A - 길이: 3
E(G1)={(A,B),(A,D),(B,C),(C,D)}
경로의 길이: 길이 경로를 구성하는 간선의 수 주기(cycle): 첫 번째 정점과 마지막 정점이 같은 경로 주기 정점의 차수: 차수 정점에 연결된 간선의 수를 말하며, 유방향 그래프에서는 진입 차수(indegree)와 진출 차수(outdegree)로 나누어 고려된다. 차수 차수
이 슬라이드에 있는 무방향 그래프에서 A의 인접정점은 B와 D이다. 경로의 길이란 경로를 구성하는 간선의 수를 말한다. 경로 중 첫 번째 정점과 끝 정점이 같으면 주기(cycle)라 한 다. 위 슬라이드에 있는 무방향 그래프에서 (A, B), (B, C), (C, D), (D, A)는 주기이며, 이 경로의 길이는 4이다. 경로의 차수(degree)란 정점에 연결된 간선의 수를 말하며, 유방향 그 래프에서는 진입차수와 진출차수를 나누어 고려한다. 위 슬라이드에 있는 무방향 그래프에 서 A의 차수는 2이고, 유방향 그래프에서 A의 진출차수는 1이고, 진입차수는 2이다.
그래프 – 계속 완전 그래프(complete graph): 최대 수의 간선을 가진 그래프 그래프 모든 정점이 인접 정점이 된다. N개의 정점이 있으면 유방향은 N(N-1)개, 무방향은 N(N-1)/2개의 간선이 존재한다.
완전 유방향 그래프 (complete directed graph)
완전 무방향 그래프 (complete undirected graph)
완전 그래프(complete graph)란 최대 수의 간선을 가진 그래프를 말한다. 즉, 모든 정점은 다른 모든 정점의 인접 정점이 된다. 유방향 그래프에서는 개의 정점이 있을 때, 이 그래 프가 완전 그래프이면 총 개의 간선이 존재한다. 무방향 그래프에서는 같은 조건 에서는 총 개의 간선이 존재한다.
- 160 -
가중치 그래프 가중치 그래프(weighted graph): 간선에 가중치가 할당되어 있는 그래프 그래프 120
10
인천
120
서울
원주
강릉
90 160 180
140
천안
250
50
대전
광주
200
60 60 80
대구
부산 80
가중치 그래프(weighted graph)란 간선에 가중치가 할당되어 있는 그래프를 말한다.
12.3. 비가중치 그래프
비가중치 그래프의 표현 방법 1. 인접 행렬
M
A E B
D B C A A
B
C
D
A
0
1
0
1
B
1
0
1
0
C
0
1
0
1
D
1
0
1
0
L
합: 정점의 차수
A
B
E
L
M
A
0
0
1
0
0
B
0
0
0
1
0
E
1
0
0
0
0
L
1
0
0
1
0
M
0
1
1
0
0
행의 합: 정점의 진출 차수 열의 합: 정점의 진입 차수
단점. 단점. n2 공간이 필요 - 무방향 행렬은 이것의 반만으로 표현가능
비가중치 그래프를 컴퓨터로 표현할 때 가장 널리 사용하는 방법은 두 가지가 있다. 첫째, 인접 행렬 방법 둘째, 인접 리스트 방법 인접 행렬 방법은 행렬을 통해 정점 간에 인접 여부를 나타낸다. 무방향 그래프의 경우에는 행렬이 대칭적이므로 실제는 만큼의 공간이 필요하다. 반면 유방향 그래프는 행렬이 대 칭적이지 않으므로 만큼의 공간이 필요하다. 유방향 그래프에서는 행의 합이 정점의 진출
차수를 나타내며, 열의 합은 진입 차수를 나타낸다.
- 161 -
비가중치 그래프의 표현 – 계속 방법 2. 인접 리스트
A
M
A E B
D B
E
B
L
E
A
L
A
L
M
B
E
C A A
B
D
B
A
C
C
B
D
D
A
C
L
단점. 단점. 진입 차수를 계산하는 것이 어렵다. - 역인접 리스트 유지
인접 리스트 표현 방법은 각 정점마다 그것의 인접한 정점들을 연결 리스트로 유지한다. 유 방향 그래프에서는 인접 행렬 표현 방법과 달리 진입 차수를 계산하는 것이 어렵다. 이를 위해 역인접 리스트를 추가로 유지하기도 한다.
Graph ADT <<interface>> Graph
MatrixGraph
WeightedMatrixGraph
ListGraph
UnweightedMatrixGraph
WeightedListGraph
DirectedWeightedMatrixGraph
UnweightedListGraph
UndirectedUnweightedListGraph
UndirectedUnweightedMatrixGraph
그래프는 크게 인접 행렬 또는 인접 리스트를 이용하는 구현할 수 있다. 따라서 Graph라는 공통 인터페이스를 정의한 후에, 인접 행렬을 이용하는 구현 방식을 위한 추상 클래스
MatrixGraph와 인접 리스트를 이용하는 구현 방식을 위한 추상 클래스를 ListGraph를 정의 한다. 그 다음에 그래프가 가중치, 비가중치 그래프인지에 따라 ListGraph는 다시 그것의 자 식
추상클래스인
WeightedListGraph,
UnweightedListGraph를
WeightedMatrixGraph, UnweightedMatrixGraph를 정의한다.
- 162 -
정의하고,
MatrixGraph는
Graph ADT import java.util.Iterator; interface Graph{ int DEF_CAPACITY = 10; int DFS = 1; // 탐색 방법을 지정하기 위한 상수 int BFS = 2; // 탐색 방법을 지정하기 위한 상수 boolean isEmpty(); boolean isFull(); void clear(); void insertVertex(String label) throws GraphOverflowException; void removeVertex(String label) throws GraphUnderflowException; void removeEdge(String from, String to) throws GraphUnderflowException; // 비가중치 그래프 // void insertEdge(String from, String to); // 가중치 그래프 // void insertEdge(String from, String to, int weight); Iterator iterator(int type, String start); }
가중치 그래프는 간선에 가중치가 연관된 그래프로서 후에 보다 자세히 설명한다. 가중치 그래프에서 간선의 추가는 추가적으로 간선의 가중치 값을 제공해야 한다. 반면에 비가중치 그래프에서 간선을 추가할 때에는 간선의 가중치가 필요 없다. public abstract ListGraph implements Graph{ protected class Vertex{ public String label; public SortedLinkedList edges; } protected class GraphIterator implements Iterator{ … } protected Vertex[] graph; protected int size = 0; // 정점의 개수 인접 리스트 표현 방법을 사용 public ListGraph(){ setup(DEF_CAPACITY); } public ListGraph(int capacity){ if(capcity>0) setup(capacity); else setup(DEF_CAPACITY); } private void setup(int capacity){ … } public boolean isEmpty(){ return (size == 0); } public boolean isFull(){ return (size == graph.length); } public void clear() { … } protected int index(String label){ … } // 정점의 색인 찾기 public void insertVertex(String label) throws GraphOverflowException { … } public abstract void removeVertex(String label) throws GraphUnderflowException; public abstract void removeEdge(String from, String to) throws GraphUnderflowException; public boolean search(int type, String from, String to) throws GraphUnderflowException { … } public Iterator iterator(int type, String start) throws GraphUnderflowException { … } }
인접 리스트 표현 방법을 사용할 때 한 가지 고려해야 하는 것은 내부적으로 정점들을 어떻 게 유지할지 결정해야 한다. 이 장에서는 배열을 이용한 비정렬 리스트로 정점들을 유지하 지만 다른 여러 가지 방법으로 유지할 수도 있다. 간선의 방향성 여부, 간선의 가중치 여부 와 관계없이 정점의 추가는 모두 동일하다. 하지만 정점의 제거, 간선의 추가, 간선의 제거 는 가선의 가중치, 방향성에 따라 다르기 때문에 이 슬라이드에 정의된 ListGraph 클래스에 서는 정의할 수 없다. 순회, 검색은 방향성에 따라 다르게 구현되어야 할 것으로 생각되지 만 그렇지 않다. 무방향 그래프에서는 간선을 추가할 때 양쪽에 모두 추가해야 한다는 측면 을 제외하고는 동일하기 때문에 순회, 검색의 구현은 방향성에 영향을 받지 않는다.
- 163 -
WeightedListGraph, UnweightedListGraph public abstract class WeightedListGraph extends ListGraph{ public WeightedListGraph(){ super(); } public WeightedListGraph(int capacity){ super(capacity); } public abstract void insertEdge(String from, String to, int weight); } public abstract class UnweightedListGraph extends ListGraph{ public WeightedListGraph(){ super(); } public WeightedListGraph(int capacity){ super(capacity); } public abstract void insertEdge(String from, String to); }
UndirectedUnweightedListGraph public abstract UndirectedUnweightedListGraph implements UnweightedGraph{ public UndirectedUnweightedListGraph(){ super(DEF_CAPACITY); } public UndirectedUnweightedListGraph(int capacity){ super(DEF_CAPACITY); } public void removeVertex(String label) throws GraphUnderflowException { … } public void removeEdge(String from, String to) throws GraphUnderflowException { … } public void insertEdge(String from, String to) throws GraphUnderflowException { … } }
이 절에서는 인접 리스트 표현 방법을 사용하여 무방향 비가중치 그래프를 구현해본다. 이 를 위해 UnweightedListGraph을 상속받은 UndirectedUnweightedListGraph 클래스를 정의한 다.
- 164 -
Insert Vertex public void insertVertex(String label) throws GraphOverflowException{ if(label==null) throw new NullPointerException(“…”); if(isFull()) throw new GraphOverflowException(“…"); Vertex v = new Vertex(); v.label = label; v.edges = new SortedLinkedList(); graph[size] = v; size++; } // ListGraph: insertVertex protected int index(String label){ for(int i=0; i<size; i++){ if(label.equals(graph[i].label)) return i; } return -1; 정점들은 배열을 이용한 비정렬 방식으로 유지 } // ListGraph: index
이 장에서는 배열을 이용한 비정렬 리스트로 정점들을 유지하기 때문에 정점의 추가는 리스 트의 맨 끝에 추가하게 된다. 정점의 제거, 간선의 추가, 간선의 제거는 모두 해당 정점이 이미 그래프에 존재해야 한다. 그래프에 주어진 이름의 정점이 있는지 파악하고 있는 경우 에는 그 정점에 관한 정보가 저장되어 있는 배열의 위치를 알아내기 위해 index라는 내부 메소드를 정의하여 사용한다.
Insert Edge public void insertEdge(String from, String to) throws GraphUnderflowException{ if(from==null||to==null) throw new NullPointerException(“…”); if(isEmpty()) throw new GraphUnderflowException(“…”); int v1 = index(from); int v2 = index(to); // v1 정점이 존재하지 않는 경우 if(v1==-1) throw new GraphUnderflowException(“…”); // v2 정점이 존재하지 않는 경우 if(v2==-1) throw new GraphUnderflowException(“…”); graph[v1].edges.insert(graph[v2].label); graph[v2].edges.insert(graph[v1].label); } // UndirectedUnweightedListGraph: insertEdge 무방향 그래프이므로 양쪽 인접리스트에 모두 추가해야 함.
간선을 추가하고자 할 경우에는 간선의 두 정점이 모두 존재해야 한다. 구현하는 그래프가 무방향 그래프이므로 A에서 B를 잇는 간선을 추가할 경우에는 B에서 A를 잇는 간선도 함 께 추가해야 한다.
- 165 -
Remove Vertex public void removeVertex(String label) throws GraphUnderflowException{ if(label==null) throw new NullPointerException(“…”); if(isEmpty()) throw new GraphUnderflowException(“…”); int v = index(label); // 제거하고자 하는 정점이 존재하지 않는 경우 if(v == -1) throw new GraphUnderflowException(“…”); graph[v] = graph[size-1]; size--; for(int i=0;i<size;i++){ if(!graph[i].edges.isEmpty()) graph[i].edges.delete(label); } } // UndirectedUnweightedListGraph: RemoveVertex 정점을 제거하는 이 정점에 연결된 모든 간선을 제거해야 한다.
정점을 제거한다는 것은 그 정점과 그 정점에 연결된 모든 간선을 제거한다는 것을 의미한 다. 따라서 그 정점을 정점들을 관리하는 비정렬 리스트에서 제거하는 것은 물론 그것의 인 접 정점들의 인접 리스트에서도 이 정점을 제거해야 한다. 이 구현에서는 모든 정점의 인접 리스트를 검색하고 있다. 이 방법은 정점이 많은 경우에는 매우 비효율적이다. 이것을 개선 하기 위한 한 가지 방법은 제거하고자 하는 정점의 인접 리스트를 이용하여 실제 인접한 정 점의 인접 리스트만 검색하여 변경할 수 있다.
Remove Edge public void removeEdge(String from, String to) throws GraphUnderflowException{ if(isEmpty()) throw new GraphUnderflowException(“…”); if(from==null||to==null) throw new NullPointerException(“…”); int v1 = index(from); int v2 = index(to); // v1 정점이 존재하지 않는 경우 if(v1==-1) throw new GraphUnderflowException(“…”); // v2 정점이 존재하지 않는 경우 if(v2==-1) throw new GraphUnderflowException(“…”); // v1에서 v2를 잇는 간선이 존재하지 않는 경우 if(!graph[v1].edges.delete(graph[v2].label)) throw new GraphUnderflowException(“…”); graph[v2].edges.delete(graph[v1].label); } // UndirectedUnweightedListGraph: RemoveEdge 무방향 그래프이므로 양쪽 인접리스트에 모두 제거해야 함.
A에서 B를 잇는 간선을 제거하고자 할 경우에는 A 정점과 B 정점이 존재해야 할 뿐만 아 니라 A에서 B를 잇는 간선이 실제 존재해야 한다. 또한 무방향 그래프이므로 A에서 B를 잇 는 간선을 제거할 때 B에서 A를 잇는 간선 정보 역시 함께 제거해야 한다.
12.4. 순회 트리에서 순회는 크게 전위, 중위, 후위 세 가지 방법이 존재한다. 그래프에서 순회는 크게
- 166 -
깊이우선 방법과 너비우선 방법 두 가지 방법이 존재한다.
Traverse 깊이우선탐색(DFS, Depth First Search) 깊이우선탐색 단계 1. 정점 i를 방문한다. 단계 2. 정점 i에 인접한 정점 중에 아직 방문하지 않은 정점이 있으면 모두 스택에 저장한다. 단계 3. 스택에서 정점을 제거하고, 이 정점을 i로 하여 단계 1부터 다시 수행한다. 만약 스택이 비어있거나 모든 정점을 방문하였으면 종료한다. A A 순회 순서: A,D,G,C,F,B,E C
순회 순서: A,C,E,D,B
C D
B
B G
D
E
E
F
깊이우선 방법은 한쪽 방향으로 갈 수 있을 때까지 계속 방문하는 방법이다. 이 방법은 스 택을 사용하여 구현한다. 위 슬라이드에 있는 첫 번째 그래프를 이용하여 깊이우선 방법을 설명하면 다음과 같다. 먼저 A에서 출발하므로 A를 스택에 push한다. 그 다음 스택에서 정 점을 제거하고 이 정점의 인접 정점을 스택에 push한다. 따라서 스택에 B와 C가 차례대로
push된다. 스택에 인접 정점들을 push한 다음에는 바로 정점을 하나 pop을 한다. C가 top 에 있으므로 이 정점의 인접 정점을 스택에 push한다. 따라서 스택에 D, E 순으로 push된 다. 현재 스택에 있는 정점은 top부터 나열하였을 때 E, D, B이다. 이런 방법으로 순회하면 그 결과는 A, C, E, D, B 순으로 방문하게 된다.
Traverse 너비우선탐색(BFS, Breadth First Search) 너비우선탐색 단계 1. 정점 i를 방문한다. 단계 2. 정점 i에 인접한 정점 중에 아직 방문하지 않은 정점이 있으면 모두 큐에 저장한다. 단계 3. 큐에서 정점을 제거하고, 이 정점을 i로 하여 단계 1부터 수행한다. 만약 큐가 비어있거나 모든 정점을 방문하였으면 종료 한다. A A 순회 순서: A,B,C,D,F,E,G C
순회 순서: A,B,C,E,D
C D
B
B G
D
E
E
F
너비우선 방법은 출발 정점에 가장 가까운 거리에 있는 정점부터 차례로 방문한다. 이 방법 은 깊이우선 방법과 달리 큐를 사용하여 구현한다. 위 슬라이드에 있는 첫 번째 그래프를 이용하여 깊이우선 방법을 설명하면 다음과 같다. 먼저 A에서 출발하므로 A를 큐에
enqueue한다. 그 다음 큐에서 정점을 dequeue하고 이 정점의 인접 정점들을 큐에 enqueue
- 167 -
한다. 따라서 큐에 B와 C가 차례대로 enqueue된다. 한 정점의 인접 정점들을 큐에
enqueue를 한 다음에는 바로 정점을 하나 dequeue한다. B가 큐 front에 있으므로 이 정점 의 인접 정점을 큐에 enqueue한다. 따라서 큐에 E가 enqueue된다. 현재 큐에 있는 정점은 큐 front부터 나열하면 C, E이다. 이런 방법으로 순회하면 그 결과는 A, B, C, E, D 순으로 방문하게 된다.
UnweightedListGraph의 반복자 클래스 public Iterator iterator(int type, String start) throws GraphUnderflowException { if(start==null) throw new NullPointerException(“…”); int v = index(start); if(v==-1) throw new GraphUnderflowException(“…”); if(type==Graph.BFS||type==Graph.DFS) return new GraphIterator(type, v); else throw new GraphUnderflowException(“…”); } protected class GraphIterator implements Iterator{ LinkedQueue traverseQueue; public GraphIterator(int type, int start){ … } public boolean hasNext() { … } public Object next() { … } public void remove() { … } private void BreadthFirstSearch(int start); private void DepthFirstSearch(int start); } public GraphIterator(int type, int start){ traverseQueue = new LinkedQueue(); if(type==Graph.BFS) BreadthFirstSearch(start); else DepthFirstSearch(start); }
그래프는 앞서 설명한 바와 같이 두 가지 방법으로 순회할 수 있다. 따라서 반복자 클래스 는 두 가지 방법으로 순회할 수 있도록 해야 한다. 여기서 traverseQueue는 각 순회 방법을 구현할 때 사용하는 큐가 아니고 각 방법에 의해 순회되는 정점의 순서를 저장하는 큐이다. 따라서 BreadthFirstSearch와 DepthFirstSearch 메소드 내에서는 traverseQueue 외에 각각 별도의 스택과 큐가 필요하다.
연결 여부 연결 그래프(connected graph): 무방향 그래프에서 서로 다른 모든 그래프 쌍의 정점들 사이에 경로가 존재하는 그래프 DFS, BFS를 이용하여 연결 여부를 확인할 수 있다. DFS(G, i): i 노드로부터 시작하여 방문한 모든 정점 이 때 G = DFS(G, i)이면 연결 그래프 어떤 연결 그래프의 모든 정점의 차수가 짝수이면 여기서 하나의 간선을 제거하여도 여전히 연결 그래프이다.
연결 그래프(connected graph)란 무방향 그래프에서 서로 다른 모든 쌍의 정점들 사이에 경 로가 존재하는 그래프를 말한다. 연결 그래프가 되기 위해서는 모든 쌍의 정점들 사이에 간 선이 존재해야 하는 것은 아니다. 만약 모든 쌍의 정점들 사이에 간선이 존재하면 이 그래
- 168 -
프를 완전 그래프라 한다. 물론 완전 그래프는 연결 그래프이다. 그래프가 연결되어 있는지 여부는 순회 방법을 이용하여 판별할 수 있다. 깊이우선 또는 너비우선으로 순회하였을 때 방문 정점들의 집합이 그래프의 모든 정점들의 집합과 일치하지 않으면 이 그래프는 비연결 그래프이다.
Spanning Tree 부분 그래프(subgraph): 다음이 성립하면 G'(V', E')는 G(V, E)의 그래프 부분 그래프라 한다. V' ⊆ V, E' ⊆ E 신장 트리(spanning tree): G의 부분 그래프 중 G의 모든 정점들을 트리 포함하는 트리 A
A
C
B
D
E
A
B
C
D
A
C
B
D
E
부분 그래프이지만 최소 신장트리가 신장 트리가 아님 아님
C
B
D
E
최소 신장트리
부분 그래프(subgraph)란 원 그래프의 정점 집합 중에 부분집합을 취하고, 원 그래프에서 이들 부분집합에 속한 정점들을 잇는 간선 중에 일부를 취해 만든 그래프를 원 그래프의 부 분 그래프라 한다. 신장 트리(spanning tree)란 원 그래프의 부분 그래프 중 원 그래프의 모 든 정점을 포함하는 트리를 말한다. 신장 트리는 순회할 때 사용하는 깊이우선 방법이나 너 비우선 방법을 이용하여 만들 수도 있다.
최소 신장 트리 최소 신장 트리(minimum spanning tree): 최소의 간선 수로 트리 구성된 그래프의 신장 트리 그래프의 정점 수가 N이면 최소 신장 트리의 간선 수는 N-1이다. DFS, BFS로 만들어진 신장 트리는 최소 신장 트리가 된다. 깊이우선 신장트리(depth first spanning tree) 신장트리 너비우선 신장트리(breadth first spanning tree) 신장트리 A
A
A
C
B
D
E
원 그래프
C
B
D
E
DFS(A)의 신장 트리
C
B
D
E
BFS(A)의 신장 트리
최소 신장 트리(minimum spanning tree)란 최소의 간선 수로 구성된 신장 트리를 말한다. 따라서 그래의 정점의 수가 이면 최소 신장 트리의 간선의 수는 이다. 깊이우선 또 는 너비우선 방법을 이용하여 만든 신장 트리는 최소 신장 트리가 된다.
- 169 -
12.5. 가중치 그래프
가중치 그래프의 표현 방법 1. 인접 리스트 서울
동경
부산
100
북경
60
180
부산 제주
북경
180
동경 홍콩
60
시애틀
1600
서울
북경
600
화와이
800
1200 100
하와이
120
홍콩
60
90
시애틀
동경
부산
-
40
20
제주
40
가중치 그래프의 표현 – 계속 방법 2. 인접 행렬 0
1
2
3
4
5
6
7
8
0
서울
0
0
60
90
100
120
180
800
1600
-
9 -
1
부산
1
60
0
-1
20
-1
-1
-1
-1
-
-
2
제주
2
90
-1
0
40
40
-1
-1
-1
-
-
3
동경
3
100
-1
-1
0
-1
-1
-1
1200
-
-
4
홍콩
4
-1
-1
-1
-1
0
60
-1
-1
-
-
5
북경
5
180
-1
-1
-1
-1
0
-1
-1
-
-
6
하와이
6
-1
-1
-1
-1
-1
-1
0
600
-
-
7
시애틀
7
1600
-1
-1
1200
-1
-1
600
0
-
-
8
-
8
-
-
-
-
-
-
-
-
-
-
9
-
9
-
-
-
-
-
-
-
-
-
-
가중치 그래프도 비가중치 그래프와 마찬가지로 인접 행렬 방법이나 인접 리스트 방법으로 표현할 수 있다. 다만 인접 리스트 방법에 경우에는 인접 리스트의 각 노드에 가중치 값을 나타내기 위한 정보가 추가되며, 인접 행렬의 각 항에는 가중치 값이 저장된다. 인접 행렬 에서 간선이 존재하지 않는 경우에는 가중치 범위에 포함되지 않는 값으로 나타내야 한다. 이 절에서는 -1 값을 이용하여 간선이 존재하지 않는다는 것을 나타낸다. 하지만 인접 행렬 을 사용할 경우에는 간선이 존재하지 않는다는 것을 나타내기 위한 적절한 값이 없을 수도 있다.
- 170 -
public abstract MatrixGraph implements Graph{ public static final int NULLEDGE = -1; protected class GraphIterator implements Iterator{ … private void setup(int capacity){ } graph = new String[capacity]; protected String[] graph; adjMatrix = new int[capacity][capacity]; protected int size = 0; // 정점의 개수 for(int i=0; i<capacity; i++) public MatrixGraph(){ for(int j=0; j<capacity; j++) setup(DEF_CAPACITY); if(i!=j) adjMatrix[i][j] = NULLEDGE; } else adjMatrix[i][j] = 0; public MatrixGraph(int capacity){ } if(capcity>0) setup(capacity); else setup(DEF_CAPACITY); } private void setup(int capacity){ … } 인접 행렬 표현 방법을 사용 public boolean isEmpty(){ return (size == 0); } public boolean isFull(){ return (size == graph.length); } public void clear() { … } protected int index(String label){ … } // 정점의 색인 찾기 public void insertVertex(String label) throws GraphOverflowException { … } public abstract void removeVertex(String label) throws GraphUnderflowException; public abstract void removeEdge(String from, String to) throws GraphUnderflowException; public boolean search(int type, String from, String to) throws GraphUnderflowException { … } public Iterator iterator(int type, String start) throws GraphUnderflowException { … } }
이 절에서는 인접 행렬 방법을 이용하여 유방향 가중치 그래프를 구현해본다. 무방향 비가 중치 그래프를 구현할 때와 마찬가지로 정점들을 정렬하여 유지하지 않는다. 이를 위해 우 선 Graph 인터페이스를 구현한 추상 클래스 MatrixGraph를 정의한다. MatrixGraph도 앞서 살펴본 ListGraph와 마찬가지로 정점의 추가, 검색, 순회는 간선의 가중치 여부, 방향성 여 부와 무관하게 구현할 수 있다.
WeightedMatrixGraph, UnweightedMatrixGraph public abstract class WeightedMatrixGraph extends MatrixGraph{ public WeightedMatrixGraph(){ super(); } public WeightedMatrixGraph(int capacity){ super(capacity); } public abstract void insertEdge(String from, String to, int weight); } public abstract class UnweightedMatrixGraph extends MatrixGraph{ public WeightedMatrixGraph(){ super(); } public WeightedMatrixGraph(int capacity){ super(capacity); } public abstract void insertEdge(String from, String to); }
- 171 -
DirectedWeightedMatrixGraph public class DirectedWeightedMatrixGraph implements WeightedListGraph{ public DirectedWeightedMatrixGraph(){ super(); } public DirectedWeightedMatrixGraph(int capacity){ super(capacity); } public void removeVertex(String label) throws GraphUnderflowException { … } public void removeEdge(String from, String to) throws GraphUnderflowException { … } public void insertEdge(String from, String to, int weight) throws GraphUnderflowException { … } }
Insert Vertex public void insertVertex(String label) throws GraphOverflowException{ if(label==null) throw new NullPointerException(“…”); if(isFull()) throw new GraphOverflowException(“…”); graph[size] = label; size++; } // MatrixGraph: insertVertex
정점들을 정렬하여 유지하지 않기 때문에 정점의 추가는 무방향 비가중치 그래프와 마찬가 지로 정점들의 이름을 저장하고 있는 배열의 끝에 추가한다.
- 172 -
Insert Edge public void insertEdge(String from, String to, int weight) throws GraphUnderflowException{ if(from==null||to==null) throw new NullPointerException(“…”); if(isEmpty()) throw new GraphUnderflowException(“…”); int v1 = index(from); int v2 = index(to); if(v1==-1) throw new GraphUnderflowException(“…”); if(v2==-1) throw new GraphUnderflowException(“…”); adjMatrix[v1][v2] = weight; } // DirectedWeightedMatrixGraph: insertEdge
유방향 가중치 그래프의 경우에는 무방향과 달리 A에서 B로 가는 아크를 추가할 때 B에서
A로 가는 아크를 자동으로 추가하지 않는다.
Remove Vertex public void removeVertex(String label) throws GraphUnderflowException{ if(label==null) throw new NullPointerException(“…”); if(isEmpty()) throw new GraphUnderflowException(“…”); int v = index(label); if(v == -1) throw new GraphUnderflowException(“…”); size--; graph[v] = graph[size]; for(int i=0;i<size; i++){ adjMatrix[v][i] = adjMatrix[size][i]; adjMatrix[size][i] = NULLEDGE; adjMatrix[i][v] = adjMatrix[i][size]; adjMatrix[i][size] = NULLEDGE; } adjMatrix[v][v] = NULLEDGE; }// DirectedWeightedMatrixGraph: insertEdge
인접 행렬 방식에서 정점의 제거는 배열을 이용한 비정렬 리스트에서 제거와 마찬가지로 맨 마지막에 있는 정점으로 제거하고자 하는 정점을 덮어 쓰면 된다. 이 때 진입과 진출 아크 를 모두 고려해야 한다.
12.6. 최단 경로 알고리즘 최단 경로 문제(shortest path problem)란 정점 간에 가장 짧은 경로를 찾는 문제를 말한다. 짧은 경로란 비가중치 그래프에서는 경로의 길이가 가장 짧은 것을 말하며, 가중치 그래프 에서는 경로에 있는 간선들의 가중치의 합이 가장 작은 것을 말한다.
- 173 -
최단 경로 알고리즘 최단 경로 문제: 문제 정점 간에 가장 짧은 경로를 찾는 문제 이 문제는 그래프의 종류에 따라 다양한 응용 문제가 존재한다. 그래프가 유한 그래프인지 무한 그래프인지? 유한 그래프: 노드의 수가 유한한 그래프 그래프가 유방향인지 무방향 그래프인지? 간선의 가중치가 모두 같은 경우, 가중치가 모두 양수인 경우, 음수의 가중치가 있는 경우(가장 어려움)
최단 경로 문제는 그래프의 종류에 따라 그것의 해결책이 다르다. 특히 그래프가 유방향인 지 무방향인지에 따라 알고리즘이 다르게 적용되어야 하며, 가중치가 있는 경우에는 가중치 값에 의해 최단 경로가 다르게 되므로 더욱 어렵다. 가장 어려운 경우는 유방향 가중치 그 래프에서 음수의 가중치를 가질 수 있는 경우이다.
최단 경로 알고리즘 1. 모든 간선의 가중치가 같은 유한 무방향 그래프에서 정점 s에서 정점 t까지 길이가 가장 짧은 경로를 찾아라. BFS 검색을 이용하면 찾을 수 있다. Moore 알고리즘: 알고리즘 정점의 수는 n이라 하자. 단계 1. ∀λ[v] = -1 λ[s] = 0; 단계 2. l = 0; 단계 3. λ[v] = l인 정점 v와 인접한 모든 정점 u 중 λ[u]가 -1이면 λ[u] = l +1, 인접한 노드가 없으면 종료한다. 단계 4. λ[t] != -1이면 종료한다. 단계 5. l = l +1; 단계 3부터 반복
먼저 모든 간선의 가중치가 같은 유한 무방향 그래프에서 최단 경로를 찾는 알고리즘을 살 펴본다. 이 알고리즘은 Moore가 제안한 알고리즘으로서 너비우선 방법을 이용한다.
- 174 -
최단 경로 알고리즘 1. – 계속 A에서 D까지
A
B
E
C
D
C에서 A까지
A
B
C
D
E
A
B
C
D
E
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
A
B
C
D
E
A
B
C
D
E
0
-1
-1
-1
-1
-1
-1
0
-1
-1
A
B
C
D
E
A
B
C
D
E
0
1
-1
-1
1
-1
1
0
1
1
A
B
C
D
E
A
B
C
D
E
0
1
2
2
1
2
1
0
1
1
Moore의 알고리즘을 예제를 통해 설명하면 다음과 같다. 먼저 정점 개수만큼의 정수 배열 을 만든다. 배열의 각 항은 우선 -1로 초기화하고, 출발 정점에 해당하는 항은 0으로 초기화 한다. 그 다음 출발 정점과 인접한 정점의 항의 값은 1로 바꾼다. 이 예제에서는 출발 정점 이 A이므로 A의 항 값은 0으로 초기화되었고, A의 인접 정점인 B와 E는 1 값을 가지게 된 다. 그 다음 배열의 항 값이 1인 각 정점에 대해 그것의 인접 정점을 찾아 이 정점들의 배 열 값을 2로 바꾼다. 따라서 B의 인접 정점인 C와 D는 2 값을 가지게 된다. 이렇게 하여 찾고자 하는 정점까지 도달하면 그 때 배열의 항에 있는 값이 최단 경로의 값이 된다.
최단 경로 알고리즘 2. 모든 간선의 가중치가 양수인 유한 유방향 그래프에서 정점 s에서 정점 t까지 길이가 가장 짧은 경로를 찾아라. Dijkstra 알고리즘 정점의 수는 n이라 하고, 정점들의 집합을 V라 하자. 단계 1. ∀λ[v] = ∞; λ[s] = 0; 단계 2. T Å V 단계 3. T 중에 λ[u]가 최소인 정점을 u라 하자. 단계 4. u가 t이면 종료 e → v 에 대해 v∈T이고 단계 5. 정점 u에서 진출하는 모든 간선 u ⎯⎯ λ[v] > λ[u]+l(e)이면 λ[v] = λ[u]+l(e) 단계 6. T Å T - {u}, 단계 3부터 반복
두 번째로 살펴볼 알고리즘은 모든 간선의 가중치가 양수인 유방향 그래프에 적용할 수 있 는 알고리즘으로서 Dijkstra가 제안한 알고리즘이다.
- 175 -
최단 경로 알고리즘 2. – 계속 A에서 D까지의 최단 경로의 예
A
5
2 3 B
E 7 1
6
B
C
D
E
0
99
99
99
99
A
B
C
D
E
0
2
3
99
5
A
B
C
D
E
0
2
3
8
5
A
B
C
D
E
0
2
3
8
4
A
B
C
D
E
0
2
3
6
4
T = {A,B,C,D,E}
T = {B,C,D,E}
C
3 D
A
2
T = {C,D,E}
T = {D,E}
T = {D}
Dijkstra 알고리즘은 Moore 알고리즘과 마찬가지로 그래프의 정점 수만큼의 배열을 사용한 다. 차이점은 배열의 각 값은 -1로 초기화되지 않고 ∞ 로 초기화 한다. 즉, 경로의 가중치 값들의 합이 ∞ 보다는 절대 클 수 없도록 ∞ 를 설정해야 한다. 출발 정점은 Moore 알고리 즘과 마찬가지로 0으로 초기화한다. 그 다음 출발 정점에서 진출하는 모든 간선이 잇는 정 점에 대해 그 간선의 가중치를 정점의 배열 값으로 저장한다. 위 슬라이드에서 A가 출발 정 점이므로 그것의 인접한 B, C, E는 각각 2, 3, 5 값을 가지게 된다. 그 다음 배열의 값 중 에서 가장 작은 값을 선택하여 그 정점과 이웃한 정점에 대해 배열 값을 갱신한다. 이 때 새롭게 계산한 경로의 값이 이미 저장되어 있는 경로의 값보다 크면 갱신하지 않는다. 위 슬라이드에서 (A, B), (B, C) 경로의 값은 (A, C) 경로의 값보다 크기 때문에 (A, B), (B, C) 경로는 배열에 반영하지 않는다. 이 과정을 모든 정점을 다 고려할 때까지 반복한다.
최단 경로 알고리즘 3. A
간선의 가중치가 음수가 될 수 있는 유한 유방향 그래프에서 정점 s에서 정점 t까지 길이가 가장 짧은 경로를 찾아라. 음의 주기가 존재하지 않음 n이 정점의 수일 때, 음의 주기가 존재하지 않으면 최단 경로의 길이는 최대 n-1이다. 기본 생각 길이가 1인 것부터 n-1인 것까지 모두 구한다.
5 3 B -2 C
A
5 3 B -2 1 C
음의 주기가 존재하는 경우
마지막으로 간선의 가중치가 음수가 될 수 있는 유방향 그래프에 적용할 수 있는 최단 경로 알고리즘을 살펴본다. 그런데 그래프 내에 음의 주기가 존재하면 최단 경로를 구할 수 없으 므로 음의 주기가 없는 경우에만 최단 경로를 찾을 수 있다. 또한 이 그래프에 있는 정점
- 176 -
의 수일 때, 음의 주기가 없으면 최단 경로의 길이는 최대 이다. 이것은 경로의 길이 가 을 넘으면 이 경로에 두 번 이상 포함된 정점이 존재하게 되므로 항상 이 경로보다 는 더 짧은 경로를 구할 수 있다.
최단 경로 알고리즘 3. – 계속 D
5 -1
3
경로의 길이=3
경로의 길이=1
-2 A
A
B
C
D
E
A
B
C
D
E
0
99
99
99
99
99
99
99
0
99
A
B
C
D
E
A
B
C
D
E
0
5
3
-2
99
99
99
99
0
7
3
B -2
E
경로의 길이=2
-1
C
경로의 길이=4
A
B
C
D
E
A
B
C
D
E
99
99
99
4
99
99
99
99
99
3
A
B
C
D
E
99
1
99
4
2
A
B
C
D
E
99
1
99
4
1
Bellman과 Ford가 제안한 알고리즘은 그래프에 개의 정점이 있을 때 출발 정점부터 경로 가 1인 것부터 것까지 모두 구한 다음 이들을 비교하여 최단 경로를 찾는다. 위 예에 서 우선 A를 출발 정점으로 경로의 길이가 1인 모든 경로를 구한다. 그 다음에 경로의 길이 가 1인 경로가 존재하면 이 경로의 끝 경로에서 진출하는 간선을 고려하면 경로의 길이가
2인 경로의 값을 구할 수 있다. 이 과정을 인 경로의 값들을 모두 구할 때까지 반복한 다.
최단 경로 알고리즘 3. – 계속 Bellman과 Bellman과 Ford 알고리즘 Distk[u]: 시작 정점 v에서 u까지 최대 k개의 아크를 포함할 수 있는 최단 경로 Dist k [u] ← min(Dist k −1 [ u],min(Dist k −1 [i ] + weight[ i , u])) D
5 -1
3
3
B -2 C
E
Dist1
A
B
C
D
E
A
0
5
3
-2
99
A
B
C
D
E
B
99
0
99
-1
99
0
5
3
-2
99
C
99
-2
99
99
-1
D
99
99
99
99
3
E
99
99
99
99
99
-2 A
-1
- 177 -
Dist2 각 정점의 진입차수 고려(열) Dist2[B] = min(5, (3+(-2))=1 Dist2[D] = min(-2, (5+(-1))=-2 Dist2[E] = min(99, min(3+(-1), -2+3))=1
제13장 2-3 트리, 2-3-4 트리 이 장에서는 2-3 트리, 2-3-4 트리에 대해 살펴본다.
13.1. 교육목표 이 장의 교육목표는 다음과 같다.
교육목표 2-3 트리 2-3-4 트리 2-3 트리와 2-3-4 트리는 AVL 트리와 마찬가지로 효율적인 검색을 위한 균형 트리 구조이다.
13.2. 2-3 트리 AVL 트리는 트리의 높이가 거의 최소 높이에 가깝도록 유지해 주지만 삽입과 삭제가 매우 복잡하다. 2-3 트리는 삽입과 삭제 연산이 AVL 트리보다는 간단하고 각 연산의 시간복잡도 가 인 트리 구조이다.
2-3 트리 2-3 트리: 트리 다음 세 가지 조건을 만족하는 트리 조건 1. 모든 중간노드들의 자식 수가 2 또는 3이다. 조건 2. 모든 단말노드가 같은 레벨에 레벨 있다. 조건 3. 다음 슬라이드에 있는 형태 조건을 만족해야 한다. 만족 자식이 둘인 노드를 2-노드라 노드 하고, 자식이 셋인 노드를 3-노드라 노드 한다. 2-3 트리는 이진 트리는 아니다. 하지만 이진 트리는 2-3 트리에서 3-노드가 없는 경우로 볼 수 있다. 따라서 2-3 트리는 같은 높이의 포화 이진 트리보다는 많은 노드를 가진다. 즉, 높이가 h이면 최소한 2h+1-1 노드를 가진다. n 노드를 가진 2-3 트리의 높이는 같은 개수의 노드로 만들 수 있는 완전 이진 트리의 높이인 ⎣⎢ log 2 n ⎦⎥ 보다 클 수 없다.
- 179 -
2-3 트리는 다음 세 가지 조건을 만족해야 한다. z 첫째, 모든 중간노드들의 자식 수가 2 또는 3이다. 이 때 자식 수가 2인 노드를 2-노드 라 하고, 자식 수가 3인 노드를 3-노드라 한다. 3-노드가 존재할 수 있으므로 2-3 트리 는 이진 트리가 아닐 수 있다. z 둘째, 모든 단말노드들이 같은 레벨에 위치한다. z 셋째, 자식 수가 둘인 경우에는 노드에 있는 값은 왼쪽 부분트리에 있는 노드들의 값보 다 크고, 중간 부분트리에 있는 노드들의 값보다는 작아야 한다. 자식 수가 셋인 경우에 는 노드에 있는 작은 값은 왼쪽 부분트리에 있는 노드들의 값보다는 크고, 중간 부분트 리에 있는 노드들의 값보다는 작아야 한다. 또한 노드에 있는 큰 값은 중간 부분트리에 있는 노드들의 값보다는 크고, 오른쪽 부분트리에 있는 노드들의 값보다는 작아야 한다. 앞서 언급한 바와 같이 2-3 트리는 이진 트리는 아니다. 하지만 2-3 트리에서 3-노드들을 모두 제거하면 이진 트리가 된다. 따라서 같은 높이의 포화 이진 트리보다는 2-3 트리가 많 은 노드를 가진다. 그런데 높이가 인 포화 이진 트리는 개의 노드를 가지므로 높 이가 인 2-3 트리는 개보다는 많은 노드를 가진다. 다시 말하면 개의 노드로 만 들 수 있는 완전 이진 트리의 높이는 이므로 개의 노드로 만들 수 있는 2-3 트리 의 높이는 보다는 작다.
2-3 트리 – 계속 2-3 트리의 형태 S
S
L
empty 2-3 tree
TL
TR
TL < S < TR TL: 2-3 트리 TR: 2-3 트리
TL
TM
TR
TL < S < TM TM < L < TR TL: 2-3 트리 TM: 2-3 트리 TR: 2-3 트리
- 180 -
class Tree23Node{ Object small; Object large; Tree23Node left; Tree23Node mid; Tree23Node right; }
2-3 트리의 예 50
20
10
15
90
65
30
45
55
60
70
75
72
120
80
100
150
순회: 3-노드인 경우
순회: 2-노드인 경우
순회: 단말 노드인 경우
inorder(left subtree) visit the small item inorder(middle subtree) visit the large item inorder(right subtree)
inorder(left subtree) visit the small item inorder(right subtree)
visit the item(s)
2-3 트리에서 검색 2-3 트리에서 검색은 이진 검색 트리에서 검색과 유사하다. 차이점. 차이점. 한 노드에서 비교해야 하는 값이 두 개일 수 있다. 2-3 트리의 높이가 최소 높이의 이진 검색 트리보다는 작거나 같으므로 보다 효율적이라고 생각할 수 있다. 그러나 한 노드에서 비교해야 하는 것이 이진 검색 트리보다 많을 수 있으므로 포화/완전 이진 검색 트리가 2-3 트리보다는 더 좋다. 이진 검색 트리의 균형을 유지하는 것은 쉽지 않으므로 평균적으로는 2-3 트리가 더 우수하다. 우수하다
2-3 트리에서 검색은 이진 검색 트리에서 검색과 유사하다. 차이점은 3-노드가 존재할 수 있으므로 이런 노드에서는 두 개의 값과 모두 비교해야 하는 경우가 발생한다. 앞서 설명한 바와 같이 2-3 트리의 높이는 최소 높이의 이진 검색 트리보다는 작거나 같다. 따라서 2-3 트리에서 검색은 이진 검색 트리에서 검색보다 효율적이라고 생각할 수 있다. 하지만 한 노 드에서 비교해야 하는 것이 이진 검색 트리보다 많을 수 있으므로 포화/완전 이진 검색 트 리가 성능은 더 우수하다. 그러나 이진 검색 트리는 삽입된 순서에 따라 효율성이 떨어질 수 있으므로 평균적으로는 2-3 트리가 효율적이다.
- 181 -
2-3 Tree: Search boolean search(Object item){ if(item == null) throw new NullPointerException(“…”); if(isEmpty()) throw new TreeUnderflowException(“…”); Comparable o = (Comparable)item; Tree23Node loc = root; while(loc!=null){ int comp = o.compareTo(loc.small); if(comp<0) loc = loc.left; else if(comp==0) return true; if(loc.large==null) loc = loc.middle; else{ comp = o.compareTo(loc.large); if(comp<0) loc = loc.middle; else if(comp==0) return true; else loc = loc.right; } } return false; }
2-3 Tree: Insert 2-3 트리에서 삽입은 항상 단말 노드에서 이루어진다. 먼저 키 값이 삽입될 단말노드를 찾는다. 단말노드가 2-node이면 그 노드에 삽입한다. 단말노드가 3-node이면 노드를 분할해야 한다. 3-node에 있던 두 개의 기존 키 값과 새 키 값을 비교하여 S, M, L을 결정한다. 그 다음에 다음과 같이 분할된다. M S
M
L S
L
즉, M은 부모 노드에 삽입된다. 이 때 부모 노드 역시 3-node이면 또 다시 분할되어야 한다. 이 과정을 반복하여 루트가 분할되면 트리의 높이가 하나 증가 한다.
2-3 트리에서 삽입은 이진 검색 트리와 마찬가지로 항상 단말노드에서 이루어진다. 따라서 먼저 키 값이 삽입될 단말노드를 찾아야 한다. 이 노드가 2-노드이면 이 노드에 키 값을 적 절하게 삽입함으로써 삽입이 완료된다. 하지만 이 노드가 3-노드이면 이 노드에 삽입할 공 간이 없으므로 이 노드를 분할해야 한다. 노드의 분할은 기존의 노드에 있던 두 개의 값과 새 값을 정렬하여 그 중에 중간 값을 부모노드로 올리고 나머지 두 개의 값은 기존 노드에 위치하게 된다. 중간 값을 부모노드로 삽입할 때 부모노드 역시 3-노드이면 이 노드 역시 분할되어야 한다. 이 과정을 반복하여 루트가 분할되면 트리의 높이가 하나 증가하게 된다.
- 182 -
2-3 Tree: Insert – 계속 노드가 분할되었을 때 그 노드의 부모가 2-node인 경우 SP
case i-1. M
SP
Z
M
S S
Z
L
L SP
case i-2. SP A
M
M A S
L
S
L
2-3 Tree: Insert – 계속 노드가 분할되었을 때 부모 노드가 3-node인 경우 SP
LP
2
1
M
SP
1
LP
SP
2
M
1
LP
2
M
case i-4. S
case i-3.
L
S
SP
LP
L
S
M
M
S
case i-5.
L
1
LP
SP
2
1
LP
S
L
L
SP
2
1
M
2
S
L
2-3 트리에서 노드의 분할은 크게 다섯 가지 경우로 나누어 생각해 볼 수 있다. 크게는 분 할되었을 때 부모노드가 2-노드인 경우와 부모노드가 3-노드인 경우로 나눌 수 있다.
- 183 -
2-3 Tree: Insert – 계속 50 50
50
50
90
split 필요 20
30
90
20
50
30
30
20 30 40
90
50
30
90
50
split 필요 20
40
90
20
40
60
90
20
40
60 80 90
50 30
20
80
40
60
90
2-3 Tree: Delete 2-3 트리에서 삭제는 먼저 삭제할 값이 포함된 노드를 찾아야 한다. 그 노드가 단말 노드가 아니면 그 값과 그것의 중위 순위 상에서 바로 앞 값을 교환한다. 이 값은 항상 단말노드에 있다. 단말 노드에서 값을 제거한다. 이 때 이 노드에 제거할 값 외에 다른 값이 있으면 그것으로 삭제는 종료된다. 그러나 그것이 유일한 값이면 추가적인 작업이 필요하다. 50
20
10
15
90
65
30
45
55
60
70
75
72
120
80
100
150
2-3 트리에서 삭제는 이진 검색 트리에서 삭제와 마찬가지로 삭제할 값이 있는 노드를 먼저 찾아야 한다. 그 노드가 단말노드가 아니면 이진 검색 트리와 마찬가지로 그 값보다 작은 값 중에 가장 큰 값을 찾아 그 값과 교체한 다음에 실제 삭제는 교체한 값이 있는 노드에서 이루어진다. 이 노드는 항상 단말노드이다. 따라서 모든 삭제는 실제 단말노드에서 이루어 진다. 삭제할 값이 있는 단말노드가 3-노드이면 그 값을 노드에서 삭제함으로써 삭제는 종 료된다. 하지만 그 노드가 2-노드일 때 값을 삭제하게 되면 빈 노드가 된다. 이 경우에는
2-3 트리의 조건을 유지하도록 형제노드와 부모노드들의 있는 값들을 재배치해야 한다.
- 184 -
2-3 Tree: Delete – 계속 단말노드에 있는 값을 제거하였을 때 그 노드에 더 이상 값이 없으면 먼저 형제노드를 검사한다. 형제 중 3-node가 있으면 그것을 이용하여 값을 재분배한다. 항상 왼쪽 형제를 먼저 이용한다. 3-node인 형제노드가 없으면 부모노드에 있는 값을 아래로 내려 인접 형제 노드들을 결합한다. 이 때 부모노드가 2-node이면 삭제 알고리즘을 반복 적용한다. 경우 1. 부모가 2-node 경우 1-1, 1-2: 3-node인 형제노드가 있는 경우 경우 1-3, 1-4: 3-node인 형제노드가 없는 경우 경우 2. 부모가 3-node 경우 2-1(a), 2-1(b), 2-1(c): 왼쪽 경우 2-2(a), 2-2(b), 2-2(c): 중간 경우 2-3(a), 2-3(b), 2-3(c): 오른쪽 c는 3-node인 형제노드가 없는 경우
삭제할 값이 있는 단말노드가 2-노드이면 우선 형제노드를 검사한다. 형제노드 중에 3-노드 가 있으면 이 노드를 이용하여 값을 재분배한다. 그런데 형제노드 중에 3-노드가 없으면 부 모노드의 값을 내리고 인접 형제노드와 결합하게 된다. 이 때 부모노드가 2-노드이면 이 과 정을 다시 반복해야 하며, 결국 루트의 있는 값을 내리게 되면 트리의 높이가 하나 감소하 게 된다.
2-3 Tree: Delete – 계속 경우 1-1, 1-2: 3-node인 형제노드가 있는 경우 P S
L
1
3
2
P
L S
4
1
-
P 2
3
4
S S
L
2
1
P 4
3
1
L 2
3
4
경우 1-3, 1-4: 3-node인 형제노드가 없는 경우 -
P S 1
2
3
S P 1
2
-
P 3
1
S 2
P 3
1
S 2
3
위 슬라이드에서는 삭제의 결과로 노드가 빈 노드가 되었을 때, 그것의 부모노드가 2-노드 인 경우에 이루어지는 과정을 보여주고 있다. 빈 노드가 되는 노드가 단말노드일 수 있고, 이 경우에는 숫자로 표시되어 있는 단말노드들은 없다고 생각하면 된다. 형제노드가 2-노드 이고 부모노드도 2-노드인 경우에는 삭제 결과 부모노드도 빈 노드가 된다.
- 185 -
-
1
S
L
A
B
2
1
2
1
2
3
3
4
C
5
6
1
2
3
A
C
4
S
5
1
4
3
-
3
4
3
1
4
2
C
3
4
5
4
1
5
B
2
B
3
B
6
S
2
1
3
4
C
A
L
3
4
5
6
S -
4
L
2
1
L
A
B
C
A
-
S
A
2
C
L
B
L
B
2
S
S
B
L
L
3
1
4
S
L
B
A
C
A
A
2
1
B
S
-
B
L
A
S
L
S
C
4
3
S
-
A
A
5
B 2
1
3
L
4
5
위 슬라이드에서는 삭제의 결과 빈 노드가 되는 노드의 부모노드가 3-노드인 경우에 삭제 과정을 보여주고 있다. 위 슬라이드에서는 총 여섯 가지 경우를 보여주고 있다. 이 외에 빈 노드가 부모노드의 중간자식인 세 가지 경우는 이 슬라이드에 생략되어 있다. 빈 노드의 형 제노드 중에 3-노드가 있으면 이것을 이용하여 값들을 재분배하게 되는데 두 개의 형제노드 가 모두 3-노드이면 가장 가까운 형제노드를 사용한다. 가장 가까운 형제노드란 가장 왼쪽 에 있는 형제노드를 제외하고는 노드의 왼쪽에 있는 노드를 말한다.
2-3 트리의 구현도 AVL 트리와 마찬가지로 삭제할 값을 찾아가거나 삽입할 노드를 찾아갈 때 스택을 사용하여 루트부터 단말노드까지 경로에 있는 노드들을 차례로 스택에 push한다. 그 다음 이 스택을 이용하여 필요한 분할 또는 삭제시 필요한 재조정을 수행한다.
13.3. 2-3-4 트리
2-3-4 트리 2-3-4 트리: 모든 중간노드들의 자식 수가 2, 3, 4이고, 모든 단말노드가 같은 레벨에 있는 트리이다. S
S
L
S
M
L
empty 2-3-4 tree
TL
TR
TL < S < TR TL: 2-3-4 트리 TR: 2-3-4 트리
TL
TM
TL < S < TM TM < L < TR TL, TM, TR: 2-3-4 트리
TR
TL
TML TMR
TR
TL < S < TML TML < M < TMR TMR < L < TR TL, TML, TML, TR: 2-3-4 트리
2-3-4 트리는 2-3 트리를 확장하여 2-노드, 3-노드 뿐만 아니라 4-노드가 존재할 수 있는 트
- 186 -
리이다. 이처럼 2-3 트리는 한 노드에 있을 수 있는 값의 수를 늘려 다양한 트리를 만들 수 있다. 실제 데이터베이스에서 사용하는 색인구조는 이런 형태의 트리를 사용한다. 한 노드 에 있을 수 있는 값의 수가 많으면 많을수록 트리의 높이는 감소한다. 또한 트리의 높이가 감소하면 할수록 검색은 향상될 수 있다. 물론 한 노드에서 비교해야 하는 정보가 많아질 수 있지만 값들이 노드에 정렬되어 저장되므로 노드 내에서 비교는 이진 검색을 할 수도 있 다.
2-3-4 트리의 예 50
30
15
20
65 75 90
55
45
60
70
72
순회: 4-노드인 경우
100 120 150
80
순회: 3-노드인 경우 2-노드인 경우 단말 노드인 경우 Æ 2-3 트리와 동일
inorder(left subtree) visit the small item inorder(middle left subtree) visit the middle item inorder(middle right subtree) visit the large item inorder(right subtree)
2-3-4 트리: 삽입 2-3-4 트리에서 삽입은 항상 단말 노드에서 이루어진다. 삽입할 노드를 찾아가는 동안 4-노드를 만나면 먼저 분할한다. insert(15) insert(20) insert(90)
insert(50) 40 10 40
40
40
80
10
80
10
50
80
10 15 20
50 80 90
insert(70)
insert(60) 40
10 15 20
80
50
40
90
10 15 20
80
50
40
60
90
10 15 20
80
50 60 70
90
2-3-4 트리에서 삽입은 2-3 트리와 유사하다. 차이점은 2-3-4 트리에서는 삽입할 노드를 찾 아가는 동안 4-노드를 만나면 먼저 분할한다. 위 슬라이드에서 처음 3개의 값이 삽입되면 트리에 있는 유일 노드가 4-노드가 된다. 그 다음에 또 다른 값을 삽입하고자 하면 이 노드 가 먼저 분할된다. 4-노드가 분할될 때에는 중간값이 부모노드로 올라가고, 왼쪽값으로만 구 성된 노드와 오른쪽값으로만 구성된 노드가 새롭게 만들어진다.
- 187 -
2-3-4 트리: 삽입 예 insert(65) 40 60 80
10 15 20
50
70
40 60 80
90
10 15 20
50
65
70
60
90
60
insert(55) 40
10 15 20
80
50
65
70
40
10 15 20
90
80
50
55
65
70
90
Ú Split할 때에는 항상 중간 값이 부모노드로 올라간다.
2-3-4 트리: 삭제 2-3-4 트리에서 삭제는 먼저 삭제할 값이 포함된 노드를 찾아야 한다. 그 노드가 단말노드가 아니면 그 값과 그것의 중위 순위 상에서 바로 앞 값을 교환한다. 이 값은 항상 단말노드에 있다. 즉, 제거는 항상 단말노드에서 이루어지도록 한다. 이 때 이 노드가 3-노드 또는 4-노드이면 값을 제거한 다음에 종료한다. 반대로 이 노드가 2-노드이면 추가 작업이 필요하다. 이런 불편함을 해소하기 위해 삭제할 값이 포함된 노드를 찾을 때 만나게 되는 모든 2-노드를 3-노드 또는 4-노드로 바꾼다. split의 반대 과정을 수행한다. 이렇게 하면 모든 제거는 한 패스에 끝낼 수 있다.
2-3-4 트리에서 삭제도 2-3 트리와 마찬가지로 삭제할 값이 포함된 노드를 먼저 찾아야 하 며, 이 노드가 중간노드이더라도 2-3 트리와 마찬가지로 결국에 삭제는 단말노드에서 이루 어진다. 삭제가 이루어진 단말노드가 3-노드 또는 4-노드이면 값을 제거함으로써 삭제는 완 료된다. 하지만 삭제가 이루어진 단말노드가 2-노드이면 빈 노드가 되므로 2-3 트리와 마찬 가지로 추가 작업을 해야 한다. 보다 삭제를 수월하게 하기 위해 2-3-4 트리에서는 삽입과 마찬가지로 삭제할 노드를 찾아가면서 만나게 되는 모든 2-노드를 3-노드 또는 4-노드를 미 리 바꾼다. 이렇게 하면 삭제할 단말노드의 부모노드는 항상 3-노드 또는 4-노드가 되므로 삭제에 따라 연쇄적으로 루트까지 다시 올라가면서 추가 작업을 하지 않아도 된다.
- 188 -
2-3-4 트리: 삭제 – 계속 2-노드를 3-노드 또는 4-노드로 바꾸는 방법 현재 노드가 P이고 삭제할 값이 있는 노드까지 경로 상에 있는 다음 자식 노드를 C라 하고, C의 가장 가까운 형제노드를 R이라 하자. P
P R
P
20
40
R R
C
40
?
C
10
20
40
R 10
?
C
?
15
20 30
40
?
20
30
R
C
15
10
30
30
C
10
2-3-4 트리에서 삭제를 할 때 먼저 루트부터 탐색을 하면서 삭제할 값이 들어있는 노드를 찾는다. 그 경로 상에 있는 노드가 2-노드가 아니면 추가 작업 없이 다음 노드로 이동한다. 이 때 현재 노드를 P라 하고 찾아갈 경로 상에 있는 P의 자식 노드를 C라 하자. C 노드가
2-노드이고 가장 가까운 형제노드가 2-노드인 경우는 다음과 같이 병합된다. 여기서 가장 가까운 형제노드란 2-3 트리에서 가장 가까운 형제노드 개념과 같다. 첫째, P가 2-노드인 경우는 P, C, 그리고 C의 형제노드가 병합되어 4-노드가 된다. 둘째, P가 3-노드인 경우는 C는 P 노드의 있는 하나의 값과 형제노드를 함께 병합하여 4노드가 되고, P 노드는 2-노드가 된다.
C 노드가 2-노드이고 가장 가까운 형제노드가 3-노드 또는 4-노드인 경우는 다음과 같이 병 합된다. 형제노드 값 중 하나가 P 노드로 올라가고 P 노드에 있는 값이 C 노드로 내려 C 노드가 3-노드가 된다.
2-3-4 트리: 삭제 예 60
40 60 80 delete(55)
40
80 10 15 20
50
65
70
90
가장 가까운 형제 노드를 찾아야 한다. 10 15 20
50
delete(50)
55
65
70
90
가장 가까운 형제 노드는 가장 왼쪽 노드를 제외하고는 자신의 왼쪽 형제를 말하며, 가장 왼쪽 노드는 자신의 오른쪽 노드를 말한다.
20 60 80 20 60 80
10
15
40
50
65
70
90 10
- 189 -
15
40
65
70
90
2-3-4 트리: 삭제 예 – 계속 delete(65)
delete(60)
20 60 80
10
15
40
70
10
20 15
10
40
70
10
15
delete(90)
15 40 80
delete(40)
90
90
20 60 80
40
15
10
20
90
10
40
15
70 80 90
20
70
70
15 40 80
10
20
20
60 40
70
20
80
10 15 40
70
80 20
70
- 190 -
80
10
15
70
80
90