1. 소개

이 기사에서는 두 개의 정렬된 배열의 합집합에서 k 번째로 작은 요소찾는 방법을 살펴보겠습니다 .

먼저 정확한 문제를 정의하겠습니다. 둘째, 두 가지 비효율적이지만 간단한 솔루션을 볼 수 있습니다. 셋째, 두 배열에 대한 이진 검색을 기반으로 하는 효율적인 솔루션을 살펴보겠습니다. 마지막으로 알고리즘이 작동하는지 확인하기 위해 몇 가지 테스트를 살펴보겠습니다.

알고리즘의 모든 부분에 대한 Java 코드 스니펫도 볼 수 있습니다. 단순화를 위해 구현은 정수에서만 작동합니다 . 그러나 설명된 알고리즘은 비교 가능하고 Generics를 사용하여 구현할 수도 있는 모든 데이터 유형에서 작동합니다.

2. 두 개의 정렬된 배열의 합집합에서 K 번째로 작은 요소 는 무엇입니까 ?

2.1. K 번째 작은 요소

찾기 위해 K TH-작은 소자도 불리는 K 배열 번째 차 통계치를, 우리는 일반적으로 사용하는 선택 알고리즘 . 그러나 이러한 알고리즘은 정렬되지 않은 단일 배열에서 작동하는 반면 이 기사에서는 두 개의 정렬된 배열에서 k 번째로 작은 요소 를 찾고자 합니다 .

문제에 대한 몇 가지 솔루션을 보기 전에 달성하고자 하는 바를 정확히 정의해 보겠습니다. 이를 위해 바로 예제로 넘어가겠습니다.

우리는 두 개의 배열 (분류 주어진 B 반드시 요소의 동일한 번호가 필요하지 않습니다) :

이 두 배열에서 k 번째로 작은 요소 를 찾고 싶습니다 . 더 구체적으로, 우리 는 결합되고 정렬된 배열에서 k 번째로 작은 요소 를 찾고 싶습니다 .

우리의 예를 위한 결합되고 정렬된 배열은 (c)에 나와 있습니다. 번째로 작은 요소는 3 이고 네 번째로 작은 요소는 20 입니다.

2.2. 중복 값

또한 중복 값을 처리하는 방법을 정의해야 합니다. 요소는 배열 중 하나( 배열 a의 요소 3) 에서 두 번 이상 발생할 수 있으며 두 번째 배열( b ) 에서도 다시 발생할 수 있습니다 .

중복을 한 번만 계산하면 (c)와 같이 계산됩니다. 요소의 모든 발생을 계산하면 (d)와 같이 계산됩니다.

이 기사의 나머지 부분에서는 (d)와 같이 중복을 계산하여 별개의 요소인 것처럼 계산합니다.

3. 두 가지 간단하지만 덜 효율적인 접근 방식

3.1. 두 배열을 결합한 다음 정렬하기

k 번째로 작은 요소 를 찾는 가장 간단한 방법 은 배열을 결합하고 정렬 한 다음 결과 배열 k 번째 요소를 반환하는 것입니다 .

int getKthElementSorted(int[] list1, int[] list2, int k) {

    int length1 = list1.length, length2 = list2.length;
    int[] combinedArray = new int[length1 + length2];
    System.arraycopy(list1, 0, combinedArray, 0, list1.length);
    System.arraycopy(list2, 0, combinedArray, list1.length, list2.length);
    Arrays.sort(combinedArray);

    return combinedArray[k-1];
}

함께 n 개의 제 배열의 길이 인 상기 제 2 어레이의 길이를 M, 우리는 결합 길이 얻을 N + m = C를 .

정렬의 복잡성이 O(c log c) 이므로 이 접근 방식의 전체 복잡성은 O(n log n) 입니다.

이 접근 방식의 단점은 어레이의 복사본을 생성해야 하므로 더 많은 공간이 필요하다는 것입니다.

3.2. 두 배열 병합

병합 정렬 정렬 알고리즘 의 단일 단계와 유사하게 두 배열을 병합 한 다음 k 번째 요소 를 직접 검색 할 수 있습니다 .

병합 알고리즘의 기본 아이디어는 첫 번째 및 두 번째 배열(a)의 첫 번째 요소를 가리키는 두 개의 포인터로 시작하는 것입니다.

그런 다음 포인터에서 두 요소( 34 ) 를 비교하고 더 작은 요소( 3 )를 결과에 추가하고 해당 포인터를 한 위치 앞으로(b) 이동합니다. 다시 포인터의 요소를 비교하고 더 작은 요소( 4 )를 결과에 추가합니다 .

결과 배열에 모든 요소가 추가될 때까지 같은 방식으로 계속합니다. 입력 배열 중 하나에 더 많은 요소가 없으면 다른 입력 배열의 나머지 요소를 모두 결과 배열에 복사합니다.

전체 배열을 복사하지 않으면 성능을 향상시킬 수 있지만 결과 배열에 k개의 요소 가 있으면 중지 합니다. 결합된 어레이에 대해 추가 어레이를 생성할 필요도 없지만 원래 어레이에서만 작동할 수 있습니다.

다음은 Java로 구현한 것입니다.

public static int getKthElementMerge(int[] list1, int[] list2, int k) {

    int i1 = 0, i2 = 0;

    while(i1 < list1.length && i2 < list2.length && (i1 + i2) < k) {
        if(list1[i1] < list2[i2]) {
            i1++;
        } else {
            i2++;
        }
    }

    if((i1 + i2) < k) {
        return i1 < list1.length ? list1[k - i2 - 1] : list2[k - i1 - 1]; 
    } else if(i1 > 0 && i2 > 0) {
        return Math.max(list1[i1-1], list2[i2-1]);
    } else {
        return i1 == 0 ? list2[i2-1] : list1[i1-1];
    }
}

이 알고리즘의 시간 복잡도가 O( k ) 임을 이해하는 것은 간단합니다 . 이 알고리즘의 장점은 중복 요소를 한 번만 고려하도록 쉽게 조정할 수 있다는 것 입니다.

4. 두 배열에 대한 이진 검색

O( k ) 보다 더 잘할 수 있습니까 ? 대답은 우리가 할 수 있다는 것입니다. 기본 아이디어는 두 배열에 대해 이진 검색 알고리즘을 수행하는 것입니다 .

이것이 작동하려면 모든 요소에 대한 지속적인 읽기 액세스를 제공하는 데이터 구조가 필요합니다. Java에서는 배열 또는 ArrayList 가 될 수 있습니다 .

구현하려는 메서드의 골격을 정의해 보겠습니다.

int findKthElement(int k, int[] list1, int[] list2)
    throws NoSuchElementException, IllegalArgumentException {

    // check input (see below)

    // handle special cases (see below)

    // binary search (see below)
}

여기에서 k 와 두 개의 배열을 인수로 전달합니다. 먼저 입력의 유효성을 검사합니다. 둘째, 몇 가지 특별한 경우를 처리한 다음 이진 검색을 수행합니다. 다음 세 섹션에서는 이 세 단계를 역순으로 살펴볼 것이므로 먼저 이진 검색, 두 번째, 특수한 경우, 마지막으로 매개변수 유효성 검사를 살펴보겠습니다.

4.1. 이진 검색

특정 요소를 찾는 표준 이진 검색에는 두 가지 가능한 결과가 있습니다. 찾고 있는 요소를 찾고 검색에 성공하거나, 찾지 못해 검색에 실패하는 것입니다. 이것은 k 번째로 작은 요소 를 찾고자 하는 우리의 경우와 다릅니다 . 여기에서는 항상 결과가 있습니다.

이를 구현하는 방법을 살펴보겠습니다.

4.1.1. 두 배열에서 올바른 수의 요소 찾기

첫 번째 배열에서 특정 수의 요소로 검색을 시작합니다. 그 숫자를 nElementsList1 이라고 합시다 . k개의 요소 가 필요하므로 nElementsList1 의 수 는 다음과 같습니다.

int nElementsList2 = k - nElementsList1;

예를 들어 k = 8 이라고 합시다 . 첫 번째 배열의 4개 요소와 두 번째 배열(a)의 4개 요소로 시작합니다.

첫 번째 배열의 네 번째 요소가 두 번째 배열의 네 번째 요소보다 크면 첫 번째 배열에서 너무 많은 요소를 가져 와서 nElementsList1 (b)을 줄일 수 있음을 알 수 있습니다 . 그렇지 않으면 요소를 너무 적게 사용하여 nElementsList1 (b')을 늘릴 수 있다는 것을 알고 있습니다 .

정지 기준에 도달할 때까지 계속합니다. 그것이 무엇인지 살펴보기 전에 지금까지 설명한 코드를 살펴보겠습니다.

int right = k;
int left = = 0;
do {
    nElementsList1 = ((left + right) / 2) + 1;
    nElementsList2 = k - nElementsList1;

    if(nElementsList2 > 0) {
        if (list1[nElementsList1 - 1] > list2[nElementsList2 - 1]) {
            right = nElementsList1 - 2;
        } else {
            left = nElementsList1;
        }
    }
} while(!kthSmallesElementFound(list1, list2, nElementsList1, nElementsList2));

4.1.2. 정지 기준

우리는 두 가지 경우에 멈출 수 있습니다. 첫째, 첫 번째 배열에서 가져온 최대 요소가 두 번째(c)에서 가져온 최대 요소와 같으면 중지할 수 있습니다. 이 경우 해당 요소를 간단히 반환할 수 있습니다.

둘째, 다음 두 가지 조건(d)이 충족되면 중지할 수 있습니다.

  • 첫 번째 배열에서 가져오는 가장 큰 요소는 두 번째 배열에서 가져오지 않은 가장 작은 요소보다 작습니다( 11 < 100 ).
  • 두 번째 배열에서 가져오는 가장 큰 요소는 첫 번째 배열에서 가져오지 않은 가장 작은 요소보다 작습니다( 21 < 27 ).

그 조건이 작동하는 이유(d')를 시각화하는 것은 쉽습니다. 두 배열에서 가져온 모든 요소는 두 배열의 다른 요소보다 확실히 작습니다.

중지 기준에 대한 코드는 다음과 같습니다.

private static boolean foundCorrectNumberOfElementsInBothLists(int[] list1, int[] list2, int nElementsList1, int nElementsList2) {

    // we do not take any element from the second list
    if(nElementsList2 < 1) {
        return true;
    }

    if(list1[nElementsList1-1] == list2[nElementsList2-1]) {
        return true;
    }

    if(nElementsList1 == list1.length) {
        return list1[nElementsList1-1] <= list2[nElementsList2];
    }

    if(nElementsList2 == list2.length) {
        return list2[nElementsList2-1] <= list1[nElementsList1];
    }

    return list1[nElementsList1-1] <= list2[nElementsList2] && list2[nElementsList2-1] <= list1[nElementsList1];
}

4.1.3. 반환 값

마지막으로 올바른 값을 반환해야 합니다. 여기에 세 가지 가능한 경우가 있습니다.

  • 두 번째 배열에서 요소를 가져오지 않으므로 대상 값은 첫 번째 배열(e)에 있습니다.
  • 대상 값은 첫 번째 배열(e')에 있습니다.
  • 대상 값은 두 번째 배열(e”)에 있습니다.

이것을 코드로 보자:

return nElementsList2 == 0 ? list1[nElementsList1-1] : max(list1[nElementsList1-1], list2[nElementsList2-1]);

첫 번째 배열에서 요소를 가져오지 않는 경우를 처리할 필요가 없다는 점에 유의하십시오. 나중에 특별한 경우를 처리할 때 해당 경우를 제외합니다.

4.2. 왼쪽 및 오른쪽 테두리의 초기 값

지금까지 첫 번째 배열의 오른쪽 및 왼쪽 경계를 k0 으로 초기화했습니다 .

int right = k;
int left = 0;

그러나 k 값에 따라 이러한 경계를 조정해야 합니다.

먼저 k 가 첫 번째 배열의 길이를 초과하면 마지막 요소를 오른쪽 경계로 사용해야 합니다. 그 이유는 배열에서 더 많은 요소를 가져올 수 없기 때문에 매우 간단합니다.

둘째, k 가 두 번째 배열의 요소 수보다 크면 첫 번째 배열에서 최소한 (k – length(list2))가져와야 한다는 것을 압니다 . 예를 들어 k = 7 이라고 합시다 . 두 번째 배열에는 4개의 요소만 있으므로 첫 번째 배열 에서 최소 3 개의 요소를 가져와야 한다는 것을 알고 있으므로 L2로 설정할 수 있습니다 .

조정된 왼쪽 및 오른쪽 테두리에 대한 코드는 다음과 같습니다.

// correct left boundary if k is bigger than the size of list2
int left = k < list2.length ? 0 : k - list2.length - 1;

// the inital right boundary cannot exceed the list1
int right = min(k-1, list1.length - 1);

4.3. 특별한 경우의 처리

실제 이진 검색을 수행하기 전에 알고리즘을 약간 덜 복잡하게 만들고 예외를 피하기 위해 몇 가지 특별한 경우를 처리할 수 있습니다. 어노테이션에 설명이 있는 코드는 다음과 같습니다.

// we are looking for the minimum value
if(k == 1) {
    return min(list1[0], list2[0]);
}

// we are looking for the maximum value
if(list1.length + list2.length == k) {
    return max(list1[list1.length-1], list2[list2.length-1]);
}

// swap lists if needed to make sure we take at least one element from list1
if(k <= list2.length && list2[k-1] < list1[0]) {
    int[] list1_ = list1;
    list1 = list2;
    list2 = list1_;
}

4.4. 입력 검증

먼저 입력 유효성 검사를 살펴보겠습니다. 예를 들어 NullPointerException 또는 ArrayIndexOutOfBoundsException  과 같은 알고리즘이 실패하고 throw되는 것을 방지하려면 세 개의 매개변수가 다음 조건을 충족하는지 확인해야 합니다.

  • 두 배열 모두 null이 아니어야 하며 요소가 하나 이상 있어야 합니다.
  • k>= 0 이어야 하며 두 배열을 합한 길이보다 클 수 없습니다.

코드의 유효성 검사는 다음과 같습니다.

void checkInput(int k, int[] list1, int[] list2) throws NoSuchElementException, IllegalArgumentException {

    if(list1 == null || list2 == null || k < 1) { 
        throw new IllegalArgumentException(); 
    }
 
    if(list1.length == 0 || list2.length == 0) { 
        throw new IllegalArgumentException(); 
    } 

    if(k > list1.length + list2.length) {
        throw new NoSuchElementException();
    }
}

4.5. 전체 코드

방금 설명한 알고리즘의 전체 코드는 다음과 같습니다.

public static int findKthElement(int k, int[] list1, int[] list2) throws NoSuchElementException, IllegalArgumentException {

    checkInput(k, list1, list2);

    // we are looking for the minimum value
    if(k == 1) {
        return min(list1[0], list2[0]);
    }

    // we are looking for the maximum value
    if(list1.length + list2.length == k) {
        return max(list1[list1.length-1], list2[list2.length-1]);
    }

    // swap lists if needed to make sure we take at least one element from list1
    if(k <= list2.length && list2[k-1] < list1[0]) {
        int[] list1_ = list1;
        list1 = list2;
        list2 = list1_;
    }

    // correct left boundary if k is bigger than the size of list2
    int left = k < list2.length ? 0 : k - list2.length - 1; 

    // the inital right boundary cannot exceed the list1 
    int right = min(k-1, list1.length - 1); 

    int nElementsList1, nElementsList2; 

    // binary search 
    do { 
        nElementsList1 = ((left + right) / 2) + 1; 
        nElementsList2 = k - nElementsList1; 

        if(nElementsList2 > 0) {
            if (list1[nElementsList1 - 1] > list2[nElementsList2 - 1]) {
                right = nElementsList1 - 2;
            } else {
                left = nElementsList1;
            }
        }
    } while(!kthSmallesElementFound(list1, list2, nElementsList1, nElementsList2));

    return nElementsList2 == 0 ? list1[nElementsList1-1] : max(list1[nElementsList1-1], list2[nElementsList2-1]);
}

private static boolean foundCorrectNumberOfElementsInBothLists(int[] list1, int[] list2, int nElementsList1, int nElementsList2) {

    // we do not take any element from the second list
    if(nElementsList2 < 1) {
        return true;
    }

    if(list1[nElementsList1-1] == list2[nElementsList2-1]) {
        return true;
    }

    if(nElementsList1 == list1.length) {
        return list1[nElementsList1-1] <= list2[nElementsList2];
    }

    if(nElementsList2 == list2.length) {
        return list2[nElementsList2-1] <= list1[nElementsList1];
    }

    return list1[nElementsList1-1] <= list2[nElementsList2] && list2[nElementsList2-1] <= list1[nElementsList1];
}

5. 알고리즘 테스트

GitHub 리포지토리에는 가능한 많은 입력 배열과 많은 코너 케이스를 다루는 많은 테스트 케이스가 있습니다.

여기에서는 정적 입력 배열에 대해 테스트하지 않고 이중 이진 검색 알고리즘의 결과를 간단한 결합 및 정렬 알고리즘의 결과와 비교하는 테스트 중 하나만 지적합니다. 입력은 두 개의 무작위 배열로 구성됩니다.

int[] sortedRandomIntArrayOfLength(int length) {
    int[] intArray = new Random().ints(length).toArray();
    Arrays.sort(intArray);
    return intArray;
}

다음 방법은 단일 테스트를 수행합니다.

private void random() {

    Random random = new Random();
    int length1 = (Math.abs(random.nextInt())) % 1000 + 1;
    int length2 = (Math.abs(random.nextInt())) % 1000 + 1;

    int[] list1 = sortedRandomIntArrayOfLength(length1);
    int[] list2 = sortedRandomIntArrayOfLength(length2);

    int k = (Math.abs(random.nextInt()) + 1) % (length1 + length2);

    int result = findKthElement(k, list1, list2);
    int result2 = getKthElementSorted(list1, list2, k);
    int result3 = getKthElementMerge(list1, list2, k);

    assertEquals(result2, result);
    assertEquals(result2, result3);
}

그리고 위의 메서드를 호출하여 다음과 같은 많은 테스트를 실행할 수 있습니다.

@Test
void randomTests() {
    IntStream.range(1, 100000).forEach(i -> random());
}

6. 결론

이 기사에서 우리는 정렬된 두 배열의 합집합에서 k 번째로 작은 요소 를 찾는 방법에 대한 여러 가지 방법을 보았습니다 . 먼저 간단하고 간단한 O(n log n) 알고리즘 을 보았고 , 그 다음에는 복잡도가 O(n)버전을 , 마지막으로 O(log n) 에서 실행되는 알고리즘을 보았습니다 .

우리가 본 마지막 알고리즘은 훌륭한 이론적 연습입니다. 그러나 가장 실용적인 목적을 위해 처음 두 알고리즘 중 하나를 사용하는 것을 고려해야 합니다. 이 알고리즘은 두 배열에 대한 이진 검색보다 훨씬 간단합니다. 물론 성능이 문제라면 이진 검색이 해결책이 될 수 있습니다.

이 기사의 모든 코드는 GitHub에서 사용할 수 있습니다.

Generic footer banner