컬렉션은 많은 정보를 담을 수 있다. 그래서 내용을 살펴보고 수정하는 것이 단일 데이터에 비해 어렵다. 컬렉션을 적절히 가공하면 정보를 이해하기 쉽게 정리하고 새로운 지식을 이끌어낼 수 있다. 이 절에서는 가장 기본적이고 많이 사용되는 컬렉션 가공법을 알아 본다.

  • 모든 요소에 연산 적용하기 (예: 음료 가격을 50 원씩 올리기)
  • 모든 요소 누적하기 (예: 음료 가격의 평균 구하기)
  • 선별하기 (예: 음료 가격이 2천 원 이하인 것만 구하기)
  • 정렬하기 (예: 음료 가격을 싼 것부터 비싼 것 순으로 정렬하기)

대화식 셸에서 컬렉션을 직접 조작하며 학습해보자. 먼저 다음 리스트를 정의해 두자.

코드 7-21 실습을 위한 음료 가격 리스트

>>> prices = [2500, 3000, 1800, 3500, 2000, 3000, 2500, 2000]

이 수 리스트는 8가지 음료를 취급하는 어느 카페의 음료 가격들이다. 쉬운 설명을 위해 매우 간단한 컬렉션을 선택했지만, 이 절에서 배울 내용은 더 복잡한 컬렉션에도 얼마든지 적용할 수 있다.

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

컬렉션을 수정할 때, 컬렉션 자체를 수정하기보다 컬렉션의 내용(즉, 컬렉션의 모든 요소)을 수정해야 하는 경우가 많다. 예를 들어, 음료 가격 리스트에서 가격을 50 원씩 인상하려면 리스트를 순회하며 모든 요소(모든 음료의 가격)에 각각 연산(50 원씩 인상)을 적용해야 한다.

for 문으로 모든 요소에 연산 적용하기

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

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

>>> new_prices = []                    # ❶ 인상된 가격을 담을 새 리스트

>>> for price in prices:               # ❷ 가격 리스트를 순회하면서
...     new_prices.append(price + 50)  #    각 가격에 50을 더해 new_prices에 담는다
... 

>>> new_prices                         # ❸ 50 원씩 인상된 가격의 리스트가 만들어졌다
[2550, 3050, 1850, 3550, 2050, 3050, 2550, 2050]

이 방법은 ❶ 수정된 요소를 저장할 컬렉션을 준비하고, ❷ for 문으로 기존 컬렉션을 순회하면서 연산을 적용하여 새 컬렉션에 담는 방법이다. ❸ 새 컬렉션을 확인해보면 모든 요소가 빠짐없이 수정되어 저장되어 있다.

기존의 prices 리스트에는 여전히 수정 전의 데이터가 들어 있다. 수정 이전의 데이터가 필요하지 않다면 새 리스트를 만든 후 prices = new_prices와 같이 전 리스트에 대입하면 된다. 가격 리스트는 다음 예제에서 계속 사용할 것이니 그대로 두자.

코드 7-22와 같은 패턴은 매우 자주 사용된다. 자주 사용되는 만큼 좀 더 간결하게 표현하는 기능이 준비되어 있다. 리스트 조건제시법이다.

리스트 조건제시법

리스트 조건제시법(list comprehension)이란, 각 요소를 구하는 조건을 제시하여 컬렉션을 정의하는 방법이다. 다음은 [2, 4, 6, 8, 10] 리스트를 여러 가지 방법으로 정의해 본 것이다.

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

원소나열법은 모든 요소를 직접 기술하는 방법이고, 레인지는 범위와 간격으로 등차수열을 나타내는 방법이다. 리스트 조건제시법은 컬렉션의 각 요소에 지정된 조건(연산)을 적용해 새 리스트를 만드는 방법이다. 원소나열법과 레인지는 새 리스트를 바로 정의할 수 있지만, 조건제시법은 기존에 정의된 컬렉션이 있어야 한다.

리스트 조건제시법은 다음과 같은 양식으로 표현할 수 있다.

[연산 for 변수 in 컬렉션]

for 문의 헤더에 대괄호를 씌운듯한 형태다. for 예약어와 in 예약어를 기준으로 연산, 변수, 원본 컬렉션을 작성한다. 이 표현을 실행하면 원본 컬렉션의 각 요소를 변수에 대입한 뒤 지정한 연산을 적용해 새 리스트로 만든다.

설명만으로는 이해가 어려울 수 있다. 코드를 직접 작성해보자. 다음은 “[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라는 식을 사용했다. 즉, 각 주기마다 요소 e에 2를 곱하라는 뜻이다. 이 식을 계산한 결과가 새 리스트의 각 요소가 된다.

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

코드 7-22에서 for 문을 이용해 음료의 가격을 50 원씩 인상했다. 리스트 조건제시법을 사용하면 동일한 작업을 더 간결하게 수행할 수 있다.

코드 7-24 리스트 조건제시법으로 음료 가격 수정하기

>>> [price + 50 for price in prices]
[2550, 3050, 1850, 3550, 2050, 3050, 2550, 2050]

리스트 조건제시법 또한 새 리스트를 생성할 뿐, 원본 리스트는 수정하지 않는다. 원본 리스트를 변경하고 싶다면 prices = [price + 50 for price in prices]와 같이 원본 리스트를 대입해 두었던 변수에 새 리스트를 대입하면 된다.

map() 함수

여기서 리스트 조건제시법과 유사한 기능을 하는 함수, map()을 봐 두면 좋다. 리스트 조건제시법은 파이썬 문법에 정의된 ‘식’이고 map()은 일반적인 ‘함수’다. map() 함수는 각 요소에 적용할 연산(함수)과 컬렉션을 전달받아, 컬렉션의 모든 요소에 연산을 적용한다. 다음 코드를 대화식 셸에 입력해 보자.

코드 7-25 map() 함수로 음료 가격 수정하기

>>> def plus50(n):             # ❶ 각 요소에 적용할 연산을 함수로 정의해 둔다
...     return n + 50
... 

>>> list(map(plus50, prices))  # ❷ prices의 각 요소에 plus50 함수를 적용한 리스트 생성하기
[2550, 3050, 1850, 3550, 2050, 3050, 2550, 2050]

map() 함수는 각 요소에 적용할 연산을 지정하기 위해 함수를 필요로 한다. 그래서 ❶ 전달받은 수에 50을 더해 반환하는 함수를 정의했다. ❷의 map() 함수에는 두 개의 인자를 전달하는데, 첫 번째 인자는 각 요소에 적용할 함수(plus50())다. 두 번째 인자는 연산을 적용할 원본 컬렉션(prices)이다. 이렇게 map() 함수를 실행하면 컬렉션의 모든 요소에 plus50() 함수가 적용된다. map() 함수는 range() 함수처럼 지연 계산법을 사용하므로 list() 함수를 통해 리스트로 변환해 주어야 결과를 바로 확인할 수 있다.

그런데 plus50() 함수는 map() 함수에서 한 번만 쓰고 마는 일회용 함수다. 일회용 함수를 def 문으로 정의해두는 것은 장황하고 번거롭다. 일회용 함수는 3.6절에서 배운 람다 식으로 표현하는 것이 좋다.

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

>>> list(map(lambda n: n + 50, prices))
[2550, 3050, 1850, 3550, 2050, 3050, 2550, 2050]

plus50() 함수는 람다 식으로 lambda n: n + 50와 같이 표현할 수 있다. 람다 식을 사용하면 간단한 연산을 한 행으로 표현할 수 있어 편리하다. 하지만 연산이 복잡해지거나 함수를 다시 사용할 필요가 있다면 def 문으로 정의하는 것이 좋다.

조건제시법과 map() 함수는 동작이 유사하고, 대부분의 경우에 서로 바꿔 쓸 수 있다. 파이썬에서는 일반적으로 map() 함수보다는 조건제시법을 사용하는 것을 권장한다. map() 함수와 람다 식은 다른 사람이 작성한 코드를 읽을 수 있도록 알아 두자.

개념 정리

  • for 문, 리스트 조건제시법, 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 모든 요소 누적하기

컬렉션의 요소가 많으면 데이터의 의미를 파악하기가 어렵다. 전체 데이터의 특성을 누적해 하나의 수치로 요약해 두면 데이터의 의미를 좀 더 쉽게 알 수 있다. 예를 들어, 모든 음료의 평균 가격이나 가장 비싼 음료 가격이 얼마인지 구하는 것이다.

for 문으로 모든 요소 누적하기

for 문으로 음료의 평균 가격과 가장 비싼 가격을 구할 수 있다. 먼저, 평균 가격부터 계산해 보자.

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

>>> total_price = 0           # ❶ 총 가격을 저장할 변수
>>> num_items = 0             #    전체 항목 개수를 저장할 변수

>>> for price in prices:      # ❷ prices의 모든 요소를 순회하며
...     total_price += price  #    각 음료 가격을 누적하고 (총 가격 구하기)
...     num_items += 1        #    전체 항목 개수를 1씩 증가시킨다 (전체 항목 개수 세기)
... 
   
>>> total_price / num_items   # ❸ 평균 구하기
2537.5

❶ 누적한 값을 저장할 변수가 필요하다. 평균을 구하려면 전체 가격과 항목 개수가 필요하므로 두 개의 변수를 정의한다. ❷ for 문으로 prices 리스트를 순회하면서 각 요소의 정보를 준비한 변수에 누적한다. 반복이 끝나면 모든 요소를 누적한 결과가 변수에 저장된다. ❸ 누적한 값을 이용해 평균을 계산한다.

이번에는 가장 비싼 음료가 얼마인지 구해 보자.

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

>>> most_expensive = 0              # ❶ 가장 비싼 가격을 기억할 변수

>>> for price in prices:            # ❷ prices의 모든 요소를 순회하며
...     if most_expensive < price:  # ❸ 요소가 가장 비싼 가격보다 크면
...         most_expensive = price  #    이 요소를 가장 비싼 가격으로 기억한다
... 

>>> most_expensive
3500

❶ 가장 비싼 가격을 기억할 most_expensive 변수를 정의한다. 이 변수의 초깃값이 다른 요소보다 커 버리면 안되는데, 음료 가격은 모두 0 이상이므로 0으로 지정해도 무방하다. ❷ for 문으로 prices 리스트를 순회하면서, ❸ 각 요소를 지금까지 발견한 가장 비싼 가격과 비교하며 가장 비싼 가격을 갱신한다. 반복이 끝나면 most_expensive에는 가장 비싼 음료 가격이 저장된다.

for 문으로 평균(합계·요소 개수)과 최댓값을 구해 보았다. 사실 합계, 요소 개수, 최솟값, 최댓값은 이렇게 코드를 직접 작성하여 구하는 것보다 sum(), len(), min(), max() 같은 통계 함수를 사용하는 것이 좋다. 하지만 프로그래밍을 하다보면 이와는 조금 다른 방식으로 데이터를 요약해야 할 때가 종종 있다. 원리를 알아두어야 필요할 때 응용할 수 있으므로 직접 구하는 계산 과정을 살펴보았다.

reduce() 함수

map() 함수로 컬렉션의 모든 요소에 연산 적용할 수 있는 것처럼, functools.reduce()라는 함수를 이용해 컬렉션 전체를 하나의 값으로 누적할 수 있다. 하지만 파이썬을 만든 귀도 반 로섬은 이 함수보다는 for 문을 직접 사용하기를 권장한다. reduce() 함수의 실행 과정을 예측하기가 까다롭기 때문이라고 한다.

연습문제

연습문제 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 선별하기

사람은 매순간 감각기관으로 쏟아져 들어오는 수많은 정보 중에서 의미 있는 것만을 추려내어 처리한다고 한다. 이처럼 프로그래밍에서도 많은 데이터 중에서 필요한 것만을 선별(filter)해야 할 때가 많다. 컬렉션의 요소를 원하는 조건에 따라 선별하는 방법을 알아보자.

for 문으로 선별하기

for 문은 컬렉션의 요소를 선별할 때도 사용할 수 있다. 음료 가격 리스트에서 2천 원 이하인 것들을 선별해 보자.

코드 7-29 for 문으로 음료 가격 선별하기

>>> filtered_prices = []                   # ❶ 조건에 맞는 가격을 담을 리스트

>>> for price in prices:                   # ❷ prices 리스트의 모든 요소를 순회하며
...     if price <= 2000:                  # ❸ 각 요소가 조건에 맞는 경우
...         filtered_prices.append(price)  #    새 리스트에 담는다
... 

>>> filtered_prices
[1800, 2000, 2000]

❶ 먼저 선별한 음료 가격을 담을 빈 리스트를 만든다. ❷ for 문으로 prices 리스트를 순회하면서 ❸ if 문으로 각 요소가 조건에 맞는지 검사해 조건을 통과하는 요소만 새 리스트에 추가한다. 반복이 완료되면 새 리스트에 조건에 맞는 요소만 선별되어 있다.

리스트 조건제시법으로 선별하기

리스트 조건제시법으로도 컬렉션의 요소를 선별할 수 있다. 리스트 조건제시법 식의 마지막에 if 조건을 추가하면 된다. 다음과 같은 양식이다.

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

리스트 조건제시법에 if 조건을 추가하면 원본 컬렉션에서 조건이 참인 요소만을 (연산을 적용하여) 새 리스트에 담는다. 음료 가격 리스트를 리스트 조건제시법으로 선별해 보자.

코드 7-30 리스트 조건제시법으로 선별하기

>>> prices  # ❶
[2500, 3000, 1800, 3500, 2000, 3000, 2500, 2000]

>>> [price for price in prices]  # ❷
[2500, 3000, 1800, 3500, 2000, 3000, 2500, 2000]

>>> [price for price in prices if price <= 2000]  # ❸
[1800, 2000, 2000]

❶과 ❷는 이해를 돕기 위해 실행한 명령으로, 실제로는 ❸만 있으면 충분하다.

  • ❶은 원본 리스트다.
  • ❷는 원본 리스트의 각 요소를 price 변수로 전달받아 price 변수를 그대로 요소로 갖는 새 리스트를 정의한 것이다. price에 아무 연산도 적용하지 않았기 때문에 원본 리스트와 새 리스트가 동일하다.
  • ❸은 조건제시법의 마지막에 if price <= 2000이라는 조건을 추가했다. 그래서 2000 이하인 가격만 새 리스트에 담았다. price를 그대로 담도록 했으므로 요소를 변경하지는 않았다. 이 한 행의 코드만으로 코드 7-29와 동일한 기능을 수행할 수 있다.

조건제시법으로 요소를 선별하는 동시에 요소에 연산을 적용할 수도 있다.

filter() 함수

filter() 함수는 컬렉션에서 조건에 맞는 요소를 선별한다. 이 함수도 map() 함수처럼 함수와 원본 컬렉션을 매개변수로 전달받는다. 다음 코드를 대화식 셸에 입력해 보자.

코드 7-31 filter() 함수를 이용해 선별하기

>>> list(filter(lambda n: n <= 2000, prices))
[1800, 2000, 2000]

filter() 함수에 전달한 첫 번째 인자는 수를 입력받아 2000 이하인지 검사하는 함수, 두 번째 인자는 원본 컬렉션이다. filter() 함수는 전달받은 컬렉션의 모든 요소를 전달받은 함수로 검사하여 검사 결과가 참인 요소만을 반환한다. 조건제시법은 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)이란 어떤 기준을 정해 컬렉션의 요소를 순서대로 재배열하는 것이다. “세계에서 커피를 세 번째로 많이 소비하는 나라는?” 같은 문제를 해결하려면 정렬이 필요하다. for 문을 이용해 단순한 정렬 알고리즘 ‘거품 정렬’을 구현해보고, 파이썬의 표준 정렬 함수 sorted()를 사용하는 방법도 알아보자.

정렬 알고리즘의 종류

정렬을 수행하는 알고리즘(algorithm, 어떤 작업을 수행하기 위한 규칙과 절차)으로는 여러 가지가 있다. 다음은 몇 가지 유명한 정렬 알고리즘이다.

  • 거품 정렬(bubble sort): 바로 옆의 두 요소를 각각 비교하는 알고리즘. 비효율적이고 정렬 속도가 느리지만 단순해서 쉽게 이해할 수 있다.
  • 퀵 정렬(quicksort): 요소 하나를 기준점으로 삼고 그보다 작은 요소와 큰 요소를 나누어 배치하는 과정을 반복하는 알고리즘.
  • 삽입 정렬(insertion sort): 각 요소를 이미 정렬된 부분의 제 위치에 삽입하는 과정을 반복하는 알고리즘.
  • 합병 정렬(merge sort): 전체를 작은 단위로 나눈 후 다시 합치면서 정렬하는 알고리즘.

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

거품 정렬로 정렬하기

거품 정렬은 비효율적이어서 실제 프로젝트에서는 잘 사용하지 않지만, 방식이 단순해서 정렬을 어떤 과정으로 수행할 수 있는지 살펴보기에는 좋다. 거품 정렬은 두 요소의 크기를 비교하고, 왼쪽 요소가 오른쪽 요소보다 크다면 두 요소의 위치를 서로 바꾸는 과정을 전체 요소에 적용한다. 먼저, 요소가 두 개인 리스트를 정렬해 보자.

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

coll = [2, 1]

if coll[0] > coll[1]:                    # ❶ 왼쪽 요소가 오른쪽 요소보다 크다면
    coll[0], coll[1] = coll[1], coll[0]  # ❷ 위치를 서로 바꾼다
print(coll)                              # ❸ 결과: [1, 2]

2 > 1은 참이므로 ❷ coll[0]coll[1]을, coll[1]coll[0]을 대입하여 두 요소의 위치를 서로 바꾼다. ❸ 결과를 출력해보면 올바르게 정렬된 것을 확인할 수 있다. 그러면 같은 방법으로 요소가 세 개인 리스트를 정렬해 보자.

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

coll = [10, 5, 1]

if coll[0] > coll[1]:                    # ❶ 첫 번째와 두 번째 요소를 비교·교환
    coll[0], coll[1] = coll[1], coll[0]
print(coll)                              # 결과: [5, 10, 1]

if coll[1] > coll[2]:                    # ❷ 두 번째와 세 번째 요소를 비교·교환
    coll[1], coll[2] = coll[2], coll[1]
print(coll)                              # 결과: [5, 1, 10]

❶ 첫 번째와 두 번째 요소를 비교·교환한다. 10 > 5는 참이므로 두 요소의 위치가 바뀌어 [5, 10, 1]이 된다. ❷ 두 번째와 세 번째 요소를 비교·교환한다. 이 때, 앞에서 위치가 바뀌었으므로 두 번째 요소는 5가 아니라 10임에 유의하자. 10 > 1은 참이므로 두 요소의 위치가 바뀌어 [5, 1, 10]이 된다.

첫 번째 요소부터 마지막 요소까지 비교·교환했는데 왜 [1, 5, 10]으로 정렬되지 않고 [5, 1, 10]으로 덜 정렬된 것일까? 5와 1이 아직 비교되지 않았기 때문이다. 거품 정렬 과정은 컬렉션을 한 번 순회하는 과정만으로는 다 정렬하지 못한다. 이 문제는 잠시 후 해결하기로 하고, 지금 수행한 한 번의 순회 과정을 for 문으로 일반화하여 임의의 길이의 리스트에서 동작할 수 있도록 수정해 보자.

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

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

for i in range(len(coll) - 1):  # ❶ 요소 개수보다 하나 적은 횟수만큼 반복한다
    if coll[i] > coll[i + 1]:   # ❷ 컬렉션의 i번째 요소와 i+1번째 요소를 비교·교환한다
        coll[i], coll[i + 1] = coll[i + 1], coll[i]

print(coll)                     # ❸ 결과: [5, 1, 9, 7, 3, 10]

❷ for 문의 각 반복 주기에서는 i번째 요소와 그 다음(i+1번째) 요소를 비교·교환한다. 이 때, ❶ for 문의 반복 횟수는 컬렉션의 요소 개수보다 하나 적어야 한다. ❷에서 컬렉션의 i+1번째 요소를 인덱싱하기 때문이다.

코드 7-34를 실행하면 거품 정렬의 한 단계가 수행된다. 컬렉션이 완전히 정렬되지는 않았지만, 가장 큰 수 10이 가장 오른쪽으로 이동해 제 자리를 찾았다. 이처럼 거품 정렬을 한 단계 수행할 때마다 남아 있는 가장 큰 수가 제 자리로 이동한다. 따라서, 거품 정렬을 수행하는 각 단계를 반복하면 전체 요소의 수 만큼 반복하면 전체 요소가 정렬될 것이다. 다음과 같이 for 문을 중첩하면 된다.

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

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

for _ in coll:                      # ❶ 컬렉션의 요소의 개수만큼 반복하여
    for i in range(len(coll) - 1):  # ❷ 각 주기마다 거품 정렬의 한 단계를 수행한다
        if coll[i] > coll[i + 1]:
            coll[i], coll[i + 1] = coll[i + 1], coll[i]

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

이제 리스트가 완전히 정렬되었다. 거품 정렬 과정을 눈으로 보기만 한다면 어렵게 느껴질 수 있지만 손으로 데이터를 비교해가며 직접 구현해보면 생각보다 쉽게 구현할 수 있다.

거품 정렬의 연산 비용

거품 정렬은 컬렉션의 길이만큼의 반복을 두 번 중첩하여 수행한다. 그래서 N개의 요소를 정렬하는데 N의 제곱 회만큼 비교·교환 연산을 해야 한다. 컬렉션의 요소가 100 개이면 약 1만 회의 연산이, 1000 개이면 약 1백만 회의 연산이 필요하다. 이처럼 연산 대상의 크기에 따라 연산 횟수가 빠르게 증가하는 알고리즘은 비효율적인 알고리즘으로 분류된다.

sorted() 함수

프로그래밍을 공부할 때는 정렬 알고리즘을 직접 구현해보는 것도 좋지만, 실제 프로젝트에서는 프로그래밍 언어가 제공하는 이미 검증된 정렬 함수를 사용하는 편이 좋다. 파이썬에서는 표준 정렬 함수 sorted()를 사용하면 된다. 이 함수에 컬렉션을 전달하면 요소를 오름차순으로 정렬한 새 리스트를 얻을 수 있다.

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

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

sorted() 함수는 새 리스트를 반환하므로 원본 컬렉션의 순서는 그대로 유지된다. 원본 컬렉션을 정렬된 리스트로 바꾸려면 sorted() 함수의 반환값을 원본 컬렉션을 담은 변수에 대입하면 된다.

코드 7-37 sorted() 함수는 원본 컬렉션을 수정하지 않는다

>>> coll = [10, 5, 1, 9, 7, 3]
>>> sorted(coll)         # coll을 정렬한 리스트 구하기
[1, 3, 5, 7, 9, 10]

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

>>> coll = sorted(coll)  # 정렬한 리스트로 바꾸기
>>> coll
[1, 3, 5, 7, 9, 10]

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

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

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

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

코드 7-39 sorted() 함수로 정렬한 결과는 리스트로 반환된다

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

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

>>> 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}]

개념 정리

  • 컬렉션을 정렬할 때 sorted() 함수를 사용할 수 있다.

연습문제

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

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

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

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

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