컬렉션을 가공해 새로운 데이터를 만들어내야 하는 경우가 있다. 몇가지 예를 들어 보자.

  • 모든 원소에 연산 적용하기: 쇼핑몰의 모든 회원에게 적립금 500점 추가하기
  • 모든 원소 누적하기: 반 전체 중간고사 성적의 평균 계산하기
  • 걸러내기: 주소록에서 이름이 ‘김’으로 시작하는 사람들 구하기
  • 정렬하기: OECD 국가 리스트를 행복도 높은 순서로 정렬하기

이 절에서는 컬렉션을 조작하는 가장 대표적인 상황인 위의 네 가지 경우를 for 문을 이용해 수행해 볼 것이다. 몇몇 경우에는 리스트 조건제시법(list comprehension, 리스트 해석 또는 리스트 컴프리헨션이라고도 번역한다)이나 전용 함수를 이용해 간결하게 해결하는 방법도 있음을 알아볼 것이다.

대화식 셸에서 컬렉션을 직접 조작하며 학습할 것이므로, 먼저 아래의 데이터를 정의해 두자.

코드 7-21 실습을 위한 점수 리스트

>>> score_list = [90, 85, 77, 50, 60, 95, 95, 92, 83, 70, 86, 55]

이것은 score_list는 12명으로 구성된 한 반 학생의 중간고사 성적을 나열한 것이다. 쉬운 이해를 위해 간단한 컬렉션을 선택했지만, 이 절의 내용은 활용하기에 따라 더 복잡한 컬렉션에도 적용할 수 있다.

7.3.1 모든 원소에 연산 적용하기

컬렉션을 순회하며 모든 원소에 동일한 연산을 적용해야 할 때가 있다. 예를 들어, “쇼핑몰의 모든 회원에게 적립금 500점 추가”라는 작업을 해야 한다면 ‘쇼핑몰의 모든 회원(모든 원소)’이 연산의 대상이고, ‘적립금 500점 추가’가 연산이다. 여기서는 시험문제에 출제 오류가 있어서 모든 학생의 성적을 5점씩 올려주어야 한다고 하자. 이 작업을 반복을 이용하는 방법과 리스트 조건제시법으로 각각 수행해 보자.

반복을 이용하는 방법

첫번째 방법은 for 문으로 컬렉션을 순회하면서 새로운 값을 계산해 새 리스트에 담는 방법이다. 다음 코드를 따라 입력해보고, 어떻게 동작되는 것인지 생각해 보자.

코드 7-22 새로운 리스트를 만들어 원소 수정해 넣기

>>> new_score_list = []       # 수정한 점수를 입력할 리스트
>>> for score in score_list:  # 수정한 점수를 new_score_list에 입력
...     new_score_list.append(score + 5)
... 
>>> new_score_list            # 수정한 리스트 내용 확인
[95, 90, 82, 55, 65, 100, 100, 97, 88, 75, 91, 60]

위 코드에서는 먼저 new_score_list라는 빈 리스트를 새로 만들었다. 이 리스트에 수정된 값을 저장할 것이다. 그 다음에는 score_list 리스트를 for 문으로 순회하면서 각 원소에 5를 더한 값을 새 리스트 new_score_list에 담았다. 그 후 new_score_list의 내용을 확인해보면 모든 원소가 빠짐없이 수정되어 저장되어 있다.

이 때, score_list에는 여전히 수정되기 전의 데이터가 담겨 있다. 수정 이전의 데이터가 더 이상 필요하지 않다면 새 리스트를 만든 후 score_list = new_score_list와 같이 전 리스트에 대입하면 된다. 점수 리스트는 다음 예제에서도 사용해야 하므로 그대로 두기로 한다.

코드 7-22과 같이 어떤 리스트의 원소 전체를 수정하여 새 리스트에 담는 방법은 매우 자주 사용되는 패턴이다. 꼭 실습해보고 그 동작 과정을 이해하도록 하자. 그런데 이 표현은 자주 사용되는 패턴임에도 길고 복잡하다. 이 패턴을 간결하게 줄여 나타내는 리스트 조건제시법을 알아보자.

리스트 조건제시법

리스트 조건제시법이란, 코드에 리스트를 작성할 때 원소를 직접 나열하는 대신 원소를 구하는 조건을 제시하는 방법이다. 여기서 다루는 내용은 새로운 내용이므로 주의깊게 읽어보는 것이 좋다. 먼저, 리스트를 표현하는 몇 가지 방법을 사람의 언어로 작성해 비교해 보자.

  • 직접 표현: [2, 4, 6, 8, 10]
  • 레인지: 2 이상 11 미만의 2씩 증가하는 수의 리스트
  • 리스트 조건제시법: [1, 2, 3, 4, 5]의 각 원소에 2를 곱한 리스트

이미 알고 있듯이 직접 표현은 모든 원소를 직접 나타내는 방법이고 레인지는 규칙을 이용해 일정한 간격의 수열을 나타내는 방법이다. 이번에 배울 리스트 조건제시법은 순회 가능한 컬렉션의 각 원소를 지정한 조건에 의해 연산하여 새로운 리스트를 만드는 방법이다. 직접 표현과 레인지는 완전히 새로운 리스트를 정의하지만, 리스트 조건제시법은 기존에 정의된 컬렉션을 변형해 새 리스트를 만든다.

리스트 조건제시법을 작성하는 양식은 다음과 같다.

[연산 for 변수 in 원본]

for 문의 헤더에 대괄호를 씌운듯한 형태다. forin을 기준으로 연산, 변수, 원본 컬렉션을 작성하도록 정의되어 있다. 이 표현을 실행하면 원본 컬렉션의 각 원소가 변수에 대입된 뒤 규칙에 따라 각각 연산되어 새 리스트에 담긴다.

설명을 여러 번 읽는 것도 좋지만 직접 코드를 작성해보는 편이 이해하기 쉽다. 다음은 “[1, 2, 3, 4, 5]의 각 원소에 2를 곱한 리스트”를 파이썬 코드로 나타낸 것이다.

코드 7-23 리스트 조건제시법으로 리스트 나타내기

>>> [e * 2 for e in [1, 2, 3, 4, 5]]
[2, 4, 6, 8, 10]

위 코드의 [e * 2 for e in [1, 2, 3, 4, 5]]를 오른쪽 항목부터 왼쪽 항목 순으로 살펴보자.

  • in 예약어의 오른쪽에는 순회 가능한 컬렉션을 입력한다. 이 컬렉션은 새 리스트를 만들기 위한 원본이다. 여기서는 [1, 2, 3, 4, 5]다.
  • for 예약어와 in 예약어 사이에는 원본 컬렉션의 각 원소를 전달받을 변수를 입력한다. 변수의 이름은 자유롭게 지을 수 있다. 여기서는 원소(element)를 뜻하는 e라는 이름을 사용했다.
  • for 예약어의 왼쪽에는 원소를 전달받은 변수를 이용해 새 리스트에 담을 값을 생성하는 연산을 입력한다. 여기서는 e * 2라는 표현을 사용했다. 이는 전달받은 원소에 2를 곱한 값을 의미한다. 이 식에 의해 계산된 결과가 새 리스트의 각 원소가 된다.

이 표현을 해석해보면 만들어지는 리스트가 [2, 4, 6, 8, 10]이라는 것을 알 수 있을 것이다.

그러면 전체 학생 성적에 5점씩을 더한 리스트를 만드는 반복문(코드 7-22)을 리스트 조건제시법을 사용해 간결하게 나타내보자.

코드 7-24 리스트 조건제시법으로 학생 성적 수정

>>> [score + 5 for score in score_list]
[95, 90, 82, 55, 65, 100, 100, 97, 88, 75, 91, 60]

이처럼 여러 행에 걸쳐 작성해야 했던 반복 패턴을 단 한 행으로 줄일 수 있다. 이 때, 리스트 조건제시법을 수행하더라도 원본 리스트가 수정되지는 않는다점에 유의하자. 원본 리스트를 변경하고 싶다면 score_list = [score + 5 for score in score_list]와 같이 원본 리스트를 대입한 변수에 새로 만들어진 리스트를 대입하면 된다.

map() 함수

파이썬은 리스트 조건제시법과 유사한 기능을 하는 함수 map()도 제공한다. 리스트 조건제시법이 특별한 문법을 통해 제공되는 기능인 것과 달리, map()은 일반적인 함수다. map() 함수는 각 원소에 적용할 연산(함수)과 컬렉션을 전달받아, 컬렉션의 모든 원소에 연산을 적용한다. 다음 코드를 대화식 셸에 입력해 보자.

코드 7-25 map() 함수를 이용해 학생 성적 수정

>>> def plus5(n):
...     """수 n을 입력받아, 5를 더해 반환한다."
...     return n + 5
... 
>>> list(map(plus5, score_list))  # score_list의 각 원소에 plus5 함수를 적용한 리스트를 반환
[95, 90, 82, 55, 65, 100, 100, 97, 88, 75, 91, 60]

실행해 봤다면 두번째 명령을 눈여겨보자. map() 함수에 전달한 첫 번째 값은 첫번째 명령에서 정의한 plus5() 함수, 두 번째 값은 학생 점수가 담긴 원본 리스트다. map() 함수는 이 리스트의 모든 원소에 plus5() 함수를 각각 적용한다. map() 함수는 range() 함수처럼 지연 계산법을 사용하므로(5장 참고) list() 함수를 통해 리스트로 변환해 주어야 결과를 바로 확인할 수 있다.

이 때, 한 번 사용하고 말 plus5() 함수를 직접 정의하는 것은 장황하고 귀찮은 일이다. 이 경우와 같이 특정 상황에서 단 한번만 사용할 함수는 def 문으로 정의하는 대신 3.6 이름이 없는 함수에서 배운 람다 식을 이용해 표현할 때가 많다.

코드 7-26 map() 함수에 람다 식 전달하기

>>> list(map(lambda n: n + 5, score_list))
[95, 90, 82, 55, 65, 100, 100, 97, 88, 75, 91, 60]

위 코드에서는 map() 함수에 plus5() 함수를 정의하여 전달하는 대신, lambda n: n * 5라는 람다 식을 이용해 이름 없는 함수를 전달했다. 이 식은 하나의 값을 n이라는 매개변수에 전달받은 후 n * 5의 값을 계산해 반환하는 함수다. 람다 식이 잘 기억나지 않는다면 3.6 이름이 없는 함수를 다시 읽어보고 오자. 람다 식은 함수의 연산이 한 행으로 표현할 만큼 단순하고 함수가 일회용이어서 이름을 붙일 필요가 없는 경우에만 사용하는 것이 좋다.

map()이 원본 컬렉션의 모든 원소에 연산(함수)을 적용한다는 것을 이해했기 바란다. 리스트 조건제시법과 map() 함수는 동작이 유사하다. 파이썬 프로그래머들은 리스트 조건제시법을 좀 더 즐겨 사용한다. 입문자는 먼저 리스트 조건제시법을 주로 활용하여 숙달하도록 하고, 다른 사람이 작성한 코드를 읽을 수 있도록 map() 함수와 람다 식도 눈에 익혀두는 것이 좋다.

연습문제

연습문제 7-7 제곱 리스트 1

리스트 [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]를 레인지와 리스트 조건제시법을 활용해 원소를 직접 나열하지 않고 작성해 보아라.

힌트: 이 리스트는 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]의 각 원소를 제곱한 것이다.

연습문제 7-8 제곱 리스트 2

연습문제 7-7에서 만든 리스트를 레인지와 map() 함수를 이용해 작성해 보아라.

7.3.2 모든 원소 누적하기

컬렉션에 담긴 내용이 많으면 한눈에 파악하기가 힘들다. 전체 데이터의 특성을 누적해 하나의 수치로 요약해 두면 해석하기가 좀더 용이하다. 모든 원소를 누적함으로써 전체 데이터의 합계와 최솟값을 구해 보자.

반복을 이용하는 방법

반복을 통해 전체 성적의 합계와 최고성적을 구할 수 있다. 먼저, 합계를 계산해 보자.

코드 7-27 성적 합계 구하기

>>> total = 0                 # 누적한 값을 저장할 변수
>>> for score in score_list:  # score_list의 모든 원소를 total에 누적
...     total += score
... 
>>> total                     # 누적한 값 확인
938

위 코드에서는 먼저 누적한 값을 저장하기 위한 변수를 정의했다. 그 후 for 문을 이용해 score_list를 순회하면서 전체 원소의 값을 누적 변수에 더해나간다. 반복이 끝나면 모든 원소의 합계가 누적 변수에 저장된다.

이번에는 최저성적이 몇 점인지 검사해 보자.

코드 7-28 최저성적 구하기

>>> lowest = 100              # 최저성적
>>> for score in score_list:  # score_list의 모든 원소를 순회
...     if score < lowest:    # 원소가 최저성적보다 낮으면 최저성적에 대입
...         lowest = score
... 
>>> lowest                    # 누적한 값 확인
50

위 코드에서도 먼저 누적할 값을 저장할 lowest 변수를 정의한다. 그런데 이 변수의 초기값은 0이 아니라 100이다. 다른 점수와 비교해 점점 더 낮은 점수를 저장해야 하므로, 가능한 최대 수치를 초기값으로 지정한 것이다. 그 다음, for 문에서는 점수 리스트의 각 원소가 lowest 변수의 값보다 작은지 검사하여 lowest에 점점 더 작은 값이 대입되도록 했다. 결국 반복이 끝나면 lowest에는 가장 작은 값이 저장된다.

반복을 통해 성적 합계와 최저성적을 구해 보았다. 사실 합계, 최솟값, 최댓값은 직접 반복을 작성해 구하기보다 sum(), min(), max() 같은 통계 합수를 사용하는 것이 좋다. 하지만 프로그래밍을 하다보면 이와는 조금 다른 방식으로 전체 데이터를 하나의 값으로 요약해야 할 때가 종종 있다. 필요할 때 응용할 수 있도록 원리를 알아두도록 하자.

reduce() 함수

‘모든 원소에 연산 적용하기’를 map() 함수로 수행할 수 있는 것처럼, 컬렉션 전체를 하나의 값으로 누적하는 기능을 하는 함수 functools.reduce()가 있다. 이 함수는 함수형 프로그래밍에서 널리 사용되지만, 파이썬 문화에서는 이 함수보다 for 문을 직접 사용하는 것을 권장한다. 이 함수의 동작을 예측하기가 어렵다는 귀도 반 로섬의 관점 때문이다.

연습문제

연습문제 7-9 시퀀스의 길이 세기

len() 함수를 사용하면 시퀀스의 길이를 셀 수 있다. 이 함수를 흉내낸 함수 length() 함수를 정의해 보아라. 이 함수는 시퀀스 하나를 매개변수로 입력받아 원소의 개수를 반환한다. 단, 이 함수를 정의할 때 len() 함수를 사용하는 것은 반칙이다.

연습문제 7-10 가장 긴 리스트 구하기

여러 개의 시퀀스를 입력받아, 그 중 가장 많은 원소를 가진 시퀀스를 반환하는 함수 longest()를 정의해 보아라.

다음은 이 함수의 실행 예다.

>>> longest([1, 2, 3], (4, 5), [], 'abcdefg', range(5))
'abcdefg'

>>> longest('파이썬', '프로그래밍')
'프로그래밍'

>>> longest(range(10), range(100), range(50))
range(0, 100)

힌트: 함수에서 정해지지 않은 여러 개의 매개변수를 전달받기 위해서는 패킹을 사용한다. (5.5 패킹과 언패킹 참고)

7.3.3 걸러내기

어떤 조건을 기준으로 컬렉션의 원소를 걸러내야 할 때가 있다. “주소록에서 이름이 ‘김’으로 시작하는 사람들 구하기”를 예로 들면, “이름이 ‘김’으로 시작한다”가 조건이 될 것이고, 이 조건에 합치하는 연락처와 그렇지 않은 연락처로 주소록을 나눌 수 있을 것이다. 이렇게 데이터를 분류하는 작업은 현실의 다양한 문제에 적용될 수 있다.

반복을 이용하는 방법

성적 리스트에서 80점 미만의 성적만 걸러내 보자. 먼저, for 문을 이용하는 방법이다.

코드 7-29 80점 미만의 성적 걸러내기

>>> below80 = []          # 조건에 맞는 성적을 담을 리스트
>>> for score in score_list:
...     if score < 80:    # 조건에 맞는 경우, 새 리스트에 담는다
...         below80.append(score)
... 
>>> below80               # 걸러낸 성적 확인
[77, 50, 60, 70, 55]

새 데이터를 담을 빈 리스트를 정의한다. for 문에서 score_list를 순회하면서 if 문으로 각 원소가 조건에 맞는지 검사해 조건을 통과하는 원소만 새 리스트에 추가한다. 반복이 완료되면 새 리스트에는 조건에 맞는 원소만 담긴다. 이 절에서 이미 유사한 반복 패턴을 여러 개 살펴보았으므로 어렵지 않을 것이다.

리스트 조건제시법으로 걸러내기

7.4.1 모든 원소에 연산 적용하기에서 리스트 조건제시법을 알아보았다. 리스트 조건제시법으로 걸러내기도 수행할 수 있다. 리스트 조건제시법 표현의 마지막에 if 조건을 추가하면 된다.

[연산 for 변수 in 원본 if 조건]

파이썬은 리스트 조건제시법의 마지막에 if 조건 표현이 있으면 조건이 참인 원소만을 (연산 적용 후) 새 리스트에 담는다. 그러면 이 양식에 따라, 80점 미만의 성적을 걸러내는 코드를 리스트 조건제시법으로 작성해 보자.

코드 7-30 리스트 조건제시법으로 걸러내기

>>> score_list
[90, 85, 77, 50, 60, 95, 95, 92, 83, 70, 86, 55]

>>> [score for score in score_list]
[90, 85, 77, 50, 60, 95, 95, 92, 83, 70, 86, 55]

>>> [score for score in score_list if score < 80]
[77, 50, 60, 70, 55]

위 예의 세 번째 코드만으로도 충분하지만, 이해를 돕기 위해 세 단계에 걸쳐 각 코드의 차이점을 나타내 보았다.

  • 첫번째 코드는 원본 리스트를 그대로 나타내는 명령이다.
  • 두번째 코드는 원본 리스트의 각 원소를 score 변수로 전달받아 score 변수를 그대로 원소로 갖는 새 리스트를 정의한 것이다. score에 아무 연산도 적용하지 않았기 때문에 원본 리스트와 새 리스트는 동일하다.
  • 세번째 코드는 마지막에 if score < 80이라는 조건을 추가했다. 이를 통해, 80 미만의 원소만 새 리스트에 담겼다. score를 그대로 담도록 했으므로, 담긴 점수에는 변동이 없다. 이 한 행의 코드가 코드 7-29와 동일한 기능을 한다.

걸러내기를 수행하는 동시에 데이터에 연산을 적용하는 것도 가능하다. 리스트 조건제시법의 첫번째 항목을 score 대신 연산(score)와 같이 연산을 적용한 표현으로 수정하면 된다.

filter() 함수

모든 원소에 연산을 적용하는 함수 map()이 있는 것처럼, 컬렉션에서 원소를 걸러내 새로운 시퀀스에 담아 반환하는 함수 filter()가 있다. 이 함수도 map() 함수처럼 함수와 컬렉션을 매개변수로 전달받는다. 다음 코드를 대화식 셸에 입력해 보자.

코드 7-31 filter() 함수를 이용해 걸러내기

>>> list(filter(lambda x: x < 80, score_list))
[77, 50, 60, 70, 55]

filter() 함수에 전달한 첫 번째 값은 수를 입력받아 80 미만인지 검사하는 함수, 두 번째 값은 학생 점수가 담긴 원본 리스트다. 람다 식은 map() 함수를 소개할 때 설명했으므로 읽을 수 있을 것이다. filter() 함수는 이 리스트의 모든 원소를 전달받은 함수로 검사한 다음, 검사 결과가 참인 원소만을 새 시퀀스에 담아 반환한다.

연습문제

연습문제 7-11 용의자 솎아내기

김파이 씨는 개 다섯 마리를 키우고 있다. 어느날 김파이 씨가 외출하고 집에 돌아오니, 누군가가 침대를 물어뜯어 부순 것이 아닌가! 서둘러 현장 조사를 해 보니 범인은 주둥이가 작고, 발이 크고, 흰색 털을 가진 개라는 것을 알 수 있었다. 김파이 씨는 용의자를 좁히기 위해 개의 정보를 정리했다. 이 데이터를 걸러내에 용의자로 의심되는 개를 모두 찾아 그 이름을 화면에 출력해라.

용의자 = [
    {'이름': '멍멍', '털': '흰색', '주둥이': '크다', '발': '크다'},
    {'이름': '킁킁', '털': '검은색', '주둥이': '작다', '발': '크다'},
    {'이름': '왈왈', '털': '흰색', '주둥이': '작다', '발': '크다'},
    {'이름': '꿀꿀', '털': '검은색', '주둥이': '작다', '발': '작다'},
    {'이름': '낑낑', '털': '흰색', '주둥이': '작다', '발': '작다'},
]

연습문제 7-12 불량율 계산

파이중공업은 여러 개의 베어링을 무작위로 선택하여 지름을 측정해 리스트에 담은 후, 이 정보를 이용해 베어링의 불량율을 계산하려 한다. 지름이 0.99 mm 이상 1.01 mm 미만인 베어링을 정상 제품이라고 가정하고, 베어링의 지름을 담은 리스트를 전달받아 불량율을 계산하는 함수 faulty_rate()를 정의해라.

다음은 이 함수로 10개의 베어링으로 불량율을 계산한 예다.

>>> diameters = [0.985, 0.992, 1.004, 0.995, 0.899, 1.001, 1.002, 1.003, 1.009, 0.998]
>>> faulty_rate(diameters)
0.2

7.3.4 정렬하기

데이터를 어떤 기준에 따라 비교해서 순서대로 나열하는 것을 정렬(sort, 소트)이라고 한다. “키 작은 순으로 줄 서요.”, “세계에서 세 번째로 초콜렛을 많이 만드는 나라는?” 같은 문제를 해결하려면 정렬이 필요하다. 간단한 정렬 방법인 거품 정렬(bubble sort)을 for 문을 이용해 구현해 본 후 파이썬의 효율적인 정렬 함수 sorted()의 사용법도 알아보자.

정렬 알고리즘의 종류

정렬은 생각보다 복잡한 작업이고, 개발된 알고리즘도 매우 다양하다. 여러 정렬 알고리즘의 방법과 효율성을 이해하려면 알고리즘 과목을 학습해야 한다. 상식 수준에서 몇가지 알고리즘을 소개하지만, 이 설명만으로 각 정렬 알고리즘을 이해하는 것은 불가능하니 “이런 것이 있구나”하고 봐 두기만 하자.

  • 거품 정렬: 바로 옆의 두 원소를 각각 비교하는 비효율적인 알고리즘. 방식이 단순해서 정렬을 처음 공부하는 사람도 쉽게 이해할 수 있다.
  • 퀵 정렬(quicksort): 하나의 원소를 기준으로 그보다 작은 원소와 큰 원소로 데이터를 분할하는 과정을 반복하는 알고리즘.
  • 삽입 정렬(insertion sort): 각 원소를 이미 정렬된 부분의 제 위치에 삽입하는 과정을 반복하는 알고리즘.
  • 합병 정렬(merge sort): 전체를 작은 단위로 나눈 후 다시 병합하는 과정에서 정렬하는 알고리즘.
  • 보고정렬(bogosort): 정렬된 결과가 우연히 나올 때까지 무작위로 섞는 알고리즘. 매우 비효율적인 알고리즘의 예다.

파이썬의 sorted() 함수는 팀소트(Timsort)라는 정렬 알고리즘을 사용한다. 이 알고리즘은 삽입 정렬과 합병 정렬을 조합한 것이다.

거품 정렬

거품 정렬은 비효율적인 정렬 방식이다. 대량의 데이터를 빠르게 정렬해야 할 때(즉, 실제의 프로젝트에서)는 사용하지 않는다. 하지만 방식이 단순해 이해하기 쉽다는 장점이 있다. 적은 양의 데이터를 간단히 정렬할 때나 정렬이 어떤 식으로 이뤄지는지 이해(교육)할 때 주로 사용된다.

거품 정렬을 구현해 보자.

먼저 두 개의 원소로 구성된 리스트를 정렬해 보자. 두 원소의 크기를 비교한 후 왼쪽 원소가 오른쪽 원소보다 크다면 두 원소의 위치를 교환하면 된다. (그렇지 않다면 그대로 두면 된다.)

코드 7-32 원소 두 개 가진 리스트 정렬하기

data = [2, 1]

# 원소 비교/교환
if data[0] > data[1]:
    data[0], data[1] = data[1], data[0]

print(data)  # [1, 2]

두 원소의 크기를 비교해 교환하는 방식을 이해했다면, 같은 방법으로 원소가 세 개인 리스트를 정렬해 보자.

코드 7-33 원소 세 개 가진 리스트 정렬하기

data = [10, 5, 1]

# 첫번째와 두번째 원소를 비교/교환
if data[0] > data[1]:
    data[0], data[1] = data[1], data[0]

print(data)  # [5, 10, 1]

# 두번째와 세번째 원소를 비교/교환
if data[1] > data[2]:
    data[1], data[2] = data[2], data[1]

print(data)  # [5, 1, 10]

먼저 10과 5를 비교해, 10이 5보다 크므로 두 원소의 수를 교환했다. [5, 10, 1]로 순서가 변경되었다. 그 다음으로 두번째와 세번째 원소를 비교하고 교환했다. 10이 1보다 크므로 순서를 바꾸었고, 그 결과는 [5, 1, 10]이 된다. 왜 [1, 5, 10]으로 완전히 정렬되지 않았을까? 5와 1은 아직 비교되지 않았기 때문이다. 이렇게 거품 정렬 과정을 한 번 거치는 것만으로는 완전한 정렬이 이루어지지 않는다. 이 문제는 나중에 해결하기로 하고, 이 한 단계를 for 문으로 일반화하여 임의의 길이의 리스트에서 동작할 수 있도록 수정해 보자.

코드 7-34 거품 정렬의 한 단계 수행하기

data = [10, 5, 1, 9, 7, 3]

# data를 순회하며, i번째 원소와 i+1번째 원소를 비교/교환
# 데이터의 개수보다 하나 적은 횟수만큼 반복한다
for i in range(len(data) - 1):
    if data[i] > data[i + 1]:
        data[i], data[i + 1] = data[i + 1], data[i]

print(data)  # [5, 1, 9, 7, 3, 10]

for 문의 각 반복에서는 i번째 데이터와 그 다음(i+1번째) 데이터를 각각 비교하여 교환한다. i+1번째 데이터를 사용하기 때문에 반복 횟수는 리스트 원소의 개수보다 하나 적다는 점에 유의하자. 이제 거품 정렬의 한 단계를 작성했다. 한 단계를 수행했음에도 데이터가 완전히 정렬되지 않았다. 가장 큰 수인 10이 가장 오른쪽으로 이동했을 뿐이다. 비교와 교환이 반복 수행되는 과정을 생각해보면, 거품 정렬의 한 단계를 수행하면 가장 큰 수가 가장 오른쪽으로 이동한다는 것을 알 수 있다. 그러면 남아있는 나머지 데이터의 개수만큼 이 단계를 반복하면 전체 데이터가 정렬될 것이다. for 문을 중첩해 이를 수행해 보자.

코드 7-35 거품 정렬의 모든 단계 수행하기

data = [10, 5, 1, 9, 7, 3]

# 거품 정렬의 한 단계를 원소 개수만큼 반복 수행한다
for _ in data:
    # data를 순회하며, i번째 원소와 i+1번째 원소를 비교/교환
    # 데이터의 개수보다 하나 적은 횟수만큼 반복한다
    for i in range(len(data) - 1):
        if data[i] > data[i + 1]:
            data[i], data[i + 1] = data[i + 1], data[i]

print(data)  # [1, 3, 5, 7, 9, 10]

이제 정상적으로 데이터가 정렬되었다. 이처럼 정렬이란 전체 데이터를 일정한 방법에 따라 비교/교환하며 순서에 맞게 나열하는 것이다. 거품 정렬 과정을 눈으로 보기만 한다면 어렵게 느껴질 수 있지만 손으로 데이터를 비교해가며 직접 구현해보면 생각보다 쉽게 구현할 수 있다.

거품 정렬의 시간 복잡도

코드 7-35에서 보듯, 거품 정렬은 데이터의 원소의 개수만큼의 반복을 두 번 중첩하여 수행된다. 따라서 N개의 데이터를 거품정렬하려면 대략 N의 제곱수(N^2)만큼의 연산이 필요하다. 코드 7-35에서 이미 정렬된 부분을 비교하지 않도록 알고리즘을 좀더 최적화할 수는 있다. 그러나 연산 횟수는 여전히 N의 제곱수에 가까운 수가 된다. N의 제곱수는 N이 커짐에 따라 지수적으로 증가하는 큰 수로, 이는 거품 정렬이 비효율적인 알고리즘이라는 뜻이다.

알고리즘의 비용은 정확한 계산 횟수가 아니라 데이터의 크기에 따라 증가하는 대략의 양을 기준으로 평가한다. 이 척도를 시간 복잡도라고 부른다.

sorted() 함수

파이썬은 데이터의 정렬을 위해 sorted() 함수를 제공한다. sorted() 함수는 다양한 형태의 데이터에서 일반적으로 빠르고 안정적으로 동작한다. 앞에서 구현한 거품 정렬은 정렬의 원리를 이해하기 위한 예일 뿐이며, 대부분의 경우 거품 정렬을 구현하기보다 sorted() 함수를 사용하는 것이 좋다.

sorted() 함수의 사용법은 어렵지 않다. 함수에 컬렉션을 입력하면 원소가 오름차순으로 정렬된 새 리스트를 얻을 수 있다.

코드 7-36 sorted() 함수로 정렬하기

>>> sorted([10, 5, 1, 9, 7, 3])
[1, 3, 5, 7, 9, 10]

이 때 반환되는 결과는 새 리스트이며, 원본 데이터의 순서는 그대로 유지된다. 원본 데이터를 정렬된 데이터로 교체하고 싶다면 sorted() 함수의 반환값을 원본 데이터의 이름에 대입하면 된다.

코드 7-37 sorted() 함수는 원본 데이터를 수정하지 않는다

>>> data = [10, 5, 1, 9, 7, 3]
>>> sorted(data)         # data를 정렬한 리스트
[1, 3, 5, 7, 9, 10]

>>> data                 # 원본의 순서는 변하지 않는다
[10, 5, 1, 9, 7, 3]

>>> data = sorted(data)  # 정렬된 데이터 대입하기
>>> data
[1, 3, 5, 7, 9, 10]

sorted() 함수는 원소의 크기를 비교할 수 있다면 수 외에도 다양한 데이터를 정렬할 수 있다. 예를 들어 '가' < '나'와 같이 문자열의 크기를 가나다순으로 비교할 수 있으므로 정렬도 가능하다.

코드 7-38 원소의 크기를 비교할 수 있다면 수가 아니라도 정렬이 가능하다

>>> sorted(['월', '화', '수', '목', '금'])
['금', '목', '수', '월', '화']

sorted() 함수가 반환하는 결과는 리스트다. 문자열이나 튜플 같은 다른 컬렉션을 정렬하더라도 리스트가 반환된다.

코드 7-39 정렬한 결과는 리스트로 반환된다

>>> sorted((5, 4, 3, 2, 1))      # 튜플의 정렬
[1, 2, 3, 4, 5]

>>> sorted('안녕하세요')         # 문자열의 정렬
['녕', '세', '안', '요', '하']

오름차순 정렬과 내림차순 정렬

작은 것에서 큰 것 순으로 정렬하는 것을 오름차순(ascending order), 반대로 큰 것에서 작은 것 순으로 정렬하는 것을 내림차순(descending order)이라고 한다. 기본적으로 sorted() 함수는 전달받은 데이터를 오름차순으로 정렬한다. 내림차순 정렬을 하려면 함수를 호출할 때 옵션 매개변수인 reverseTrue로 지정하면 된다. ‘reverse’는 ‘뒤집다’라는 뜻이다.

코드 7-40 오름차순 정렬과 내림차순 정렬

>>> sorted([3, 5, 1, 2, 4])                # 오름차순 정렬
[1, 2, 3, 4, 5]

>>> sorted([3, 5, 1, 2, 4], reverse=True)  # 내림차순 정렬
[5, 4, 3, 2, 1]

sorted() 함수를 호출할 때 key 매개변수를 이용해 각 원소를 가공한 뒤 비교하도록 할 수 있다. key 매개변수는 값 하나를 입력받아 (변환한) 값을 반환하는 함수를 전달받는다. 예를 들어, 리스트의 원소를 절대값으로 비교해 정렬하고 싶다면 key 매개변수에 abs 함수를 전달할 수 있다. 또, 각 원소가 사전일 때 사전의 특정 값을 꺼내 비교하도록 지시할 수도 있다.

코드 7-41 key 매개변수 지정하기

>>> # 절대값으로 비교
>>> sorted([3, -5, 1, -2, -4], key=abs)
[1, -2, 3, -4, -5]

>>> # 사전의 특정 키-값으로 비교
>>> items = [
...     {'name': '아메리카노', 'price': 2000},
...     {'name': '카페 라떼', 'price': 2500},
...     {'name': '카푸치노', 'price': 2400},
... ]
>>> sorted_items = sorted(items, key=lambda item: item['price'])
>>> pprint.pprint(sorted_items)
[{'name': '아메리카노', 'price': 2000},
 {'name': '카푸치노', 'price': 2400},
 {'name': '카페 라떼', 'price': 2500}]

이상으로 프로그램을 만들 때 가장 많이 사용되는 컬렉션 조작 방법을 알아보았다. 이미 존재하는 데이터를 가공해 새 데이터를 구해야 할 때가 많다. 리스트 조건제시법과 컬렉션 조작 함수를 숙지하면 데이터를 편리하게 조작할 수 있다. 좀 더 복잡한 조작은 for 문을 사용해 수행할 수 있다.

연습문제

연습문제 7-13 길이로 정렬하기

문자열이 담긴 리스트를 sorted() 함수로 정렬하면 가나다순으로 정렬된다.

>>> fruits = ['배', '사과', '복숭아', '블루베리']
>>> sorted(fruits)
['배', '복숭아', '블루베리', '사과']

문자열을 길이를 기준으로 정렬하려면 어떻게 해야 할까? sorted() 함수를 활용해 위의 fruits 데이터를 이름이 긴 것에서 짧은 것 순서로 정렬하라. 정렬 결과는 다음과 같아야 한다.

['블루베리', '복숭아', '사과', '배']