사전 개방 주소법
알고리즘

개방 주소법

gabury1고친 사람 github-actions[bot]

개방 주소법은 해시테이블에서 두 키가 같은 칸으로 갈 때 뒤에 온 키를 표 안의 다른 빈 칸으로 보내는 방법입니다. 가려던 칸이 차 있으면 정해진 규칙으로 다음 칸을 봅니다. 빈 칸을 만나면 거기에 담습니다. 표 밖에 목록을 따로 매달지 않습니다.

쉽고 빠른 이해

개방 주소법은 넣으려던 칸이 차 있으면 옆의 빈 칸을 찾아 담는 방법입니다. 키 damson 은 3번 칸에 가야 합니다. 3번이 차 있으면 4번, 5번을 차례로 보고 빈 칸에 넣습니다.

이게 없으면 겹친 항목을 버리거나, 표 밖에 목록을 하나 만들어 칸마다 매달아야 합니다. 목록을 매달면 항목마다 딸린 메모리를 더 쓰고, 찾을 때 표 밖으로 나갔다 와야 합니다.

어떻게 도나:

  1. 키를 숫자로 바꿔 갈 칸을 고릅니다
  2. 그 칸이 차 있으면 정해진 규칙으로 다음 칸을 봅니다
  3. 빈 칸을 만나면 담습니다. 찾을 때도 같은 규칙으로 훑습니다

대가는 둘입니다. 표가 차 갈수록 빈 칸이 드물어져 훑어야 하는 칸 수가 가파르게 늡니다. 그리고 항목을 지울 때 칸을 그냥 비우면 뒤에 밀려 있던 항목을 못 찾게 되어, 지웠다는 표시를 따로 남겨야 합니다.

상세

이 절은 넣기·찾기·지우기를 칸 여덟 개짜리 표에서 따라간 뒤, 다음 칸을 고르는 규칙 셋과 적재율, 분리 연쇄법과의 차이까지 봅니다.

키가 겹치는 까닭

해시 함수는 키를 숫자 하나로 바꿉니다. 그 숫자가 해시값입니다.

해시값을 칸 수로 나눈 나머지가 키를 담을 칸 번호입니다. 이 칸을 버킷이라고도 부릅니다. 이 글에서는 계속 「칸」이라고 쓰겠습니다.

키로 올 수 있는 값은 끝없이 많습니다. 칸 수는 정해져 있습니다. 그래서 서로 다른 키가 같은 칸 번호를 받는 일을 피할 수 없습니다. 이 겹침이 해시 충돌입니다.

충돌이 났을 때 두 항목을 어디에 둘지 정하는 일이 충돌 해소입니다. 큰 갈래는 둘입니다. 분리 연쇄법은 칸마다 목록을 매달아 겹친 항목을 그 목록에 이어 붙입니다. 개방 주소법은 표 밖으로 나가지 않고 표 안의 다른 빈 칸을 찾습니다.

두 갈래가 겹친 키를 어디로 보내는지를 그림으로 보면 이렇습니다.

flowchart TD
    H["키 두 개가 같은 3번 칸으로 간다"]
    H --> A1
    H --> B1
    subgraph OA["개방 주소법 · 표 안의 빈 칸으로 보낸다"]
        A1["3번 칸 · cherry"] --> A2["4번 칸 · 뒤에 온 키"]
    end
    subgraph SC["분리 연쇄법 · 칸에 목록을 매단다"]
        B1["3번 칸"] --> B2["cherry"] --> B3["뒤에 온 키"]
    end

지정 주차 칸 비유

차마다 대야 할 칸 번호가 정해진 주차장을 떠올려 봅니다. 내 칸은 3번입니다. 거기 다른 차가 이미 있으면 4번, 5번으로 한 칸씩 내려가 빈 칸을 찾습니다.

나중에 그 차를 찾는 사람도 같은 규칙을 따릅니다. 3번부터 시작해 한 칸씩 내려갑니다.

넣기와 찾기

키가 갈 첫 칸은 해시값이 정합니다. 그 칸이 차 있으면 다음 후보를 계산해 봅니다. 후보 칸을 차례로 만들어 내는 일이 탐사입니다. 그렇게 만들어진 칸 번호의 줄을 탐사 순서라고 부릅니다.

가장 단순한 규칙은 한 칸씩 옆으로 옮기는 것입니다. 칸이 여덟 개인 표라면 첫 칸이 3번인 키의 j 번째 후보는 (3 + j) % 8 입니다. 나머지 연산을 쓰는 까닭은 표의 끝에 닿았을 때 0번 칸으로 되돌아오기 위해서입니다.

여덟 칸을 늘어놓고 키 damson 이 앉을 칸을 따라가 보겠습니다.

flowchart TD
    K["키 damson · 첫 칸은 3번"] --> C3
    subgraph T["칸 여덟 개짜리 표"]
        C0["0번 · 빈 칸"] --> C1["1번 · apple"]
        C1 --> C2["2번 · 빈 칸"]
        C2 --> C3["3번 · cherry"]
        C3 -->|"차 있다"| C4["4번 · banana"]
        C4 -->|"차 있다"| C5["5번 · 빈 칸"]
        C5 --> C6["6번 · 빈 칸"]
        C6 --> C7["7번 · fig"]
        C7 -->|"나머지 연산이 0번으로 되돌린다"| C0
    end
    C5 --> P["빈 칸을 만났다 · damson 을 5번에 담는다"]

찾을 때도 같은 순서로 훑습니다. 3번부터 시작해 키가 같은 항목을 만나면 그것이 답입니다.

빈 칸을 만나면 그 키가 표에 없다고 판정하고 멈춥니다. 넣을 때도 빈 칸을 만나는 순간 거기에 담았을 테니, 빈 칸 너머에 그 키가 있을 수 없기 때문입니다. 이 판정이 개방 주소법의 조회를 짧게 끝내 줍니다.

지우기가 끊는 탐사 사슬

앞 그림의 표에서 banana 를 지운다고 해 보겠습니다. 4번 칸을 그냥 빈 칸으로 되돌리면 어떻게 되는지를 아래 표가 보입니다.

칸 3번 4번 5번
지우기 전 cherry banana damson
4번을 빈 칸으로 되돌린 뒤 cherry 빈 칸 damson

이제 damson 을 찾으면 3번에서 시작해 4번에서 빈 칸을 만납니다. 조회는 거기서 없다고 판정하고 멈춥니다. 5번에 멀쩡히 있는 항목을 못 찾는 것입니다.

그래서 개방 주소법은 지운 칸에 「여기 있던 것을 지웠다」는 표시를 남깁니다. 이 표시가 묘비입니다. 조회는 묘비를 만나면 멈추지 않고 지나갑니다. 새로 넣을 때는 묘비가 놓인 칸을 빈 칸처럼 써도 됩니다.

조회는 칸마다 셋 중 하나를 보고 멈출지 말지를 정합니다.

flowchart TD
    S["첫 칸 3번부터 훑는다"] --> Q1{"빈 칸인가"}
    Q1 -->|"빈 칸이다"| A1["표에 없다 · 멈춘다"]
    Q1 -->|"아니다"| Q2{"묘비인가"}
    Q2 -->|"묘비다 · 다음 후보 칸으로"| Q1
    Q2 -->|"아니다"| Q3{"찾는 키와 같나"}
    Q3 -->|"같다"| A2["찾았다 · 멈춘다"]
    Q3 -->|"다르다 · 다음 후보 칸으로"| Q1

묘비는 공짜가 아닙니다. 넣고 지우기를 오래 되풀이하면 묘비만 늘어납니다. 그러면 담긴 항목이 적어도 조회가 길어집니다. 이때는 표를 새로 잡고 살아 있는 항목만 다시 담습니다. 이 일이 리해싱입니다.

다음 칸을 고르는 세 규칙

앞에서는 한 칸씩 옆으로 가는 규칙만 썼습니다. 규칙을 바꾸면 훑는 칸이 흩어지는 모양이 달라집니다. 널리 쓰는 셋을 아래 표가 견줍니다. h 는 해시값이 정한 첫 칸 번호입니다. j 는 몇 번째 후보인지를 0부터 세는 수입니다.

규칙 몇 번째 후보 칸 얻는 것 대가
선형 탐사 h + j 이웃 칸이라 메모리에서 함께 딸려 온다 찬 칸이 이어 붙어 덩어리가 된다
이차 탐사 h + j² 후보가 멀리 흩어져 덩어리가 덜 생긴다 첫 칸이 같은 키끼리는 똑같은 줄을 탄다
이중 해싱 h₁ + j × h₂ 키마다 걸음 폭이 달라 줄이 갈린다 해시를 두 번 계산한다

첫 줄이 말하는 메모리 이점부터 풀어 두겠습니다. 메모리는 칸 하나씩 실어 오지 않습니다. 이웃한 여러 칸을 한 묶음으로 함께 실어 옵니다. 그래서 바로 옆 칸은 대개 이미 읽혀 있습니다. 이 이점이 캐시 지역성입니다.

여덟 칸짜리 표에서 첫 칸이 3번이면 밟는 칸도 규칙마다 다릅니다. 선형 탐사는 3·4·5·6 으로 붙어 갑니다. 이차 탐사는 3·4·7 로 튀었다가 다시 4번으로 돌아옵니다.

이중 해싱은 해시 함수를 둘 씁니다. 첫 함수 h₁ 이 첫 칸을 정합니다. 둘째 함수 h₂ 는 키마다 다른 걸음 폭을 냅니다.

이 걸음 폭은 0이 되면 안 됩니다. 폭이 0이면 같은 칸을 무한히 다시 보게 됩니다.

폭을 아무 수로나 잡아도 되는 것은 아닙니다. 칸이 여덟 개인 표에서 걸음 폭이 4면 3번 다음은 7번, 그다음은 다시 3번입니다. 칸 두 개만 오가고 나머지 여섯은 못 봅니다.

그래서 걸음 폭은 칸 수와 공약수가 없게 고릅니다. 칸 수를 소수로 잡는 구현이 많은 까닭입니다.

몰림(클러스터링)

선형 탐사에서는 찬 칸이 이어 붙어 한곳에 몰립니다. 이 몰림이 클러스터링입니다. 이 글에서는 그 뭉치를 「덩어리」라고 부르겠습니다.

덩어리가 문제인 까닭은 스스로 자라기 때문입니다. 덩어리 안 어느 칸으로든 떨어진 키는 덩어리 끝까지 훑은 뒤 그 뒤에 붙습니다. 그러면 덩어리가 한 칸 더 길어집니다. 길어진 덩어리는 더 많은 키를 받아들입니다.

세 단계로 끊어 보면 되먹임이 드러납니다.

flowchart TD
    subgraph S1["1 · 찬 칸 셋이 이어 붙는다"]
        A["3·4·5 번 칸이 차 있다"]
    end
    subgraph S2["2 · 덩어리로 떨어진 키는 끝까지 밀린다"]
        B["첫 칸이 3·4·5 중 하나면 6번에 붙는다"]
    end
    subgraph S3["3 · 덩어리가 네 칸이 된다"]
        C["이제 3·4·5·6 이 입구다 · 받을 키가 늘었다"]
    end
    S1 --> S2 --> S3
    S3 -.->|"같은 일이 되풀이된다"| S1

이차 탐사와 이중 해싱은 후보를 멀리 떨어뜨려 이 되먹임을 끊습니다. 대신 캐시 지역성의 이점을 내줍니다. 어느 쪽이 나은지는 표의 크기와 항목 수가 정합니다.

적재율과 복잡도

표에 담긴 항목 수를 칸 수로 나눈 값이 적재율입니다. 개방 주소법은 항목이 전부 표 안에 있으므로 적재율이 1을 넘을 수 없습니다. 칸이 다 차면 더 넣지 못합니다.

훑어야 하는 칸 수는 적재율이 1에 가까워질수록 가파르게 늘어납니다. 표에 없는 키를 찾을 때가 가장 오래 걸립니다. 빈 칸을 만날 때까지 멈출 수 없기 때문입니다. 후보 칸이 고르게 흩어진다고 볼 때 이때 훑는 칸 수는 평균 1 / (1 - 적재율) 쯤입니다.

적재율 없는 키를 찾을 때 훑는 칸 수
0.5 2
0.75 4
0.9 10
0.99 100

값 넷만으로는 「가파르다」가 잘 안 보이므로 같은 식을 곡선으로도 그려 둡니다.

xychart-beta
    title "적재율이 오를수록 훑는 칸 수"
    x-axis "적재율" ["0.5", "0.6", "0.7", "0.8", "0.9", "0.95", "0.99"]
    y-axis "평균 훑는 칸 수" 0 --> 100
    line [2, 2.5, 3.3, 5, 10, 20, 100]

적재율 0.99 줄이 요점입니다. 칸을 거의 다 채우면 조회 하나가 수십, 수백 칸을 훑습니다. 그래서 구현은 적재율이 정해 둔 선을 넘는 순간 칸 수를 늘리고 모든 항목을 다시 담습니다.

적재율을 그 선 아래로 유지하면 넣기·찾기·지우기가 모두 평균 O(1) 입니다. 항목이 백만 개든 천 개든 훑는 칸 수가 몇 개로 일정하다는 뜻입니다.

평균이 그렇다는 것입니다. 가장 오래 걸리는 경우는 O(n) 입니다. 해시 함수가 키를 고르게 흩지 못해 모든 키가 한 줄로 몰리면 표를 끝까지 훑게 됩니다.

공간은 칸 배열 하나면 끝입니다. 항목마다 다음 항목을 가리키는 포인터를 붙일 일이 없어서, 같은 항목 수라면 분리 연쇄법보다 딸린 메모리가 적습니다.

분리 연쇄법과의 차이

둘 다 항목을 버리지 않습니다. 겹친 항목을 표 안에 두느냐 표 밖에 두느냐가 갈림입니다. 그 하나의 차이가 아래 다섯 줄을 전부 만듭니다.

개방 주소법 분리 연쇄법
겹친 항목을 두는 곳 표 안의 다른 빈 칸 칸에 매단 목록
항목마다 딸린 메모리 없다 목록을 잇는 포인터
메모리 읽기 이웃 칸이 함께 딸려 온다 목록을 따라 흩어진 곳을 짚는다
지우기 묘비를 남겨야 한다 목록에서 빼면 끝난다
적재율 1을 넘을 수 없다 1을 넘어도 굴러간다

메모리 읽기 줄이 개방 주소법을 고르는 가장 흔한 이유입니다. 선형 탐사가 다음에 볼 칸은 앞서 말한 그 묶음 안에 대개 이미 들어와 있습니다.

지우기 줄은 반대 방향입니다. 지우기가 잦은 표라면 묘비와 리해싱을 감당해야 합니다. 목록에서 빼면 끝나는 분리 연쇄법 쪽이 손이 덜 갑니다.

쓸 때와 안 쓸 때

아래 표는 하려는 일마다 개방 주소법이 맞는지를 가립니다.

하려는 일 개방 주소법이 맞나
담을 항목 수를 대강 알고 칸을 넉넉히 잡을 수 있다 맞다. 적재율을 낮게 두면 훑는 칸이 몇 개로 끝난다
조회가 잦고 메모리 읽기를 아껴야 한다 맞다. 캐시 지역성의 이점을 받는다
넣기와 지우기를 끝없이 되풀이한다 조심해야 한다. 묘비가 쌓여 리해싱이 잦아진다
항목 수를 전혀 가늠할 수 없다 안 맞다. 적재율이 1에 닿으면 더 넣지 못한다
항목 하나가 커서 칸에 담기 곤란하다 안 맞다. 목록에 매다는 분리 연쇄법을 쓴다

항목 수를 못 가늠하는 경우가 개방 주소법의 가장 큰 제약입니다. 표가 넘칠 때 늘리는 일을 구현이 알아서 해 주지 않는 환경이라면, 칸 수를 잡는 일이 그대로 설계 부담이 됩니다.

관련 항목

다음 칸을 고르는 규칙

선형 탐사 · 이차 탐사 · 이중 해싱 · 균등 해싱 · 탐사 · 탐사 순서

이것을 비틀어 배치를 바꾼 기법

로빈 후드 해싱 · 뻐꾸기 해싱 · 홉스카치 해싱 · 선형 탐사 압축

같은 역할을 두고 겨루는 충돌 해소 방식

분리 연쇄법 · 체이닝 · 외부 연쇄법 · 오버플로 영역

이것이 올라타는 자료구조와 그 부품

해시테이블 · 해시 함수 · 해시값 · 버킷 · 배열 · 해시 충돌 · 충돌 해소

조회 길이를 좌우하는 지표

적재율 · 클러스터링 · 균등 분포 · 리해싱 · 캐시 지역성

지운 칸에 남기는 표시와 그 뒤처리

묘비 · 리빌드 · 단편화

다른 이름: open addressing · 열린 주소법 · 오픈 어드레싱