Cari Elemen Terkecil Kth dalam Dua Susun Susun di Jawa

1. Pengenalan

Dalam artikel ini, kita akan melihat bagaimana mencari elemen terkecil k dalam penyatuan dua tatasusunan yang disusun.

Pertama, kita akan menentukan masalah yang tepat. Kedua, kita akan melihat dua penyelesaian yang tidak cekap tetapi mudah. Ketiga, kita akan melihat penyelesaian yang berkesan berdasarkan carian binari pada dua tatasusunan. Akhirnya, kami akan melihat beberapa ujian untuk mengesahkan bahawa algoritma kami berfungsi.

Kami juga akan melihat coretan kod Java untuk semua bahagian algoritma. Untuk kesederhanaan, pelaksanaan kami hanya akan beroperasi pada bilangan bulat . Walau bagaimanapun, algoritma yang dijelaskan berfungsi dengan semua jenis data yang setanding dan bahkan dapat dilaksanakan menggunakan Generik.

2. Apakah Elemen Terkecil K dalam Gabungan Dua Susun Susun?

2.1. The K th Terkecil Element

Untuk mencari elemen k -terkecil, juga disebut statistik k -urutan ke-dalam, dalam satu array, kami biasanya menggunakan algoritma pemilihan. Walau bagaimanapun, algoritma ini beroperasi pada satu array yang tidak disusun, sedangkan dalam artikel ini, kita ingin mencari elemen terkecil k dalam dua susunan yang disusun.

Sebelum kita melihat beberapa penyelesaian untuk masalah tersebut, mari kita tentukan dengan tepat apa yang ingin kita capai. Untuk itu, mari kita masuk ke dalam contoh.

Kami diberi dua susunan yang disusun ( a dan b ), yang tidak semestinya mempunyai bilangan elemen yang sama:

Dalam dua tatasusunan ini, kita ingin mencari elemen terkecil k . Lebih khusus lagi, kami ingin mencari elemen terkecil k dalam susunan gabungan dan disusun:

Susunan gabungan dan disusun untuk contoh kami ditunjukkan dalam (c). The 1st unsur terkecil adalah 3 , dan 4 elemen terkecil ialah 20 .

2.2. Gandakan Nilai

Kita juga perlu menentukan cara menangani nilai pendua. Elemen boleh berlaku lebih dari sekali dalam salah satu array (elemen 3 dalam array a ) dan juga berlaku lagi pada array kedua ( b ).

Sekiranya kita mengira pendua sekali, kita akan menghitung seperti yang ditunjukkan di (c). Sekiranya kita mengira semua kejadian elemen, kita akan menghitung seperti yang ditunjukkan dalam (d).

Di baki artikel ini, kami akan menghitung pendua seperti yang ditunjukkan dalam (d), sehingga menghitungnya seolah-olah mereka adalah unsur yang berbeza.

3. Dua Pendekatan Mudah tetapi Kurang Efisien

3.1. Sertailah dan Kemudian Susun Dua Susunan

Cara termudah untuk mencari elemen terkecil k adalah dengan menggabungkan tatasusunan, menyusunnya, dan mengembalikan elemen k dari susunan yang dihasilkan:

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

Dengan n menjadi panjang array pertama dan m panjang array kedua, kita mendapat panjang gabungan c = n + m .

Oleh kerana kerumitan untuk jenisnya adalah O (c log c) , kerumitan keseluruhan pendekatan ini adalah O (n log n) .

Kelemahan pendekatan ini ialah kita perlu membuat salinan susunan, yang menghasilkan lebih banyak ruang yang diperlukan.

3.2. Gabungkan Dua Susunan

Mirip dengan satu langkah algoritma penyortiran Merge Sort, kita dapat menggabungkan dua tatasusunan dan kemudian mengambil semula elemen k .

Idea asas algoritma penggabungan adalah bermula dengan dua penunjuk, yang menunjuk pada elemen pertama susunan pertama dan kedua (a).

Kami kemudian membandingkan dua elemen ( 3 dan 4 ) pada penunjuk, tambahkan yang lebih kecil ( 3 ) ke hasilnya, dan gerakkan penunjuk itu ke satu kedudukan ke depan (b). Sekali lagi, kami membandingkan elemen pada titik dan menambahkan yang lebih kecil ( 4 ) pada hasilnya.

Kami meneruskan dengan cara yang sama sehingga semua elemen ditambahkan ke susunan yang dihasilkan. Sekiranya salah satu susunan input tidak mempunyai lebih banyak elemen, kami hanya menyalin semua elemen yang tersisa dari array input yang lain ke array hasil.

Kita dapat meningkatkan prestasi jika kita tidak menyalin susunan penuh, tetapi berhenti apabila array yang dihasilkan mempunyai unsur k . Kami bahkan tidak perlu membuat susunan tambahan untuk susunan gabungan tetapi boleh beroperasi pada susunan asal sahaja.

Berikut adalah pelaksanaan di 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  0 && i2 > 0) { return Math.max(list1[i1-1], list2[i2-1]); } else { return i1 == 0 ? list2[i2-1] : list1[i1-1]; } }

Sangat mudah untuk memahami kerumitan masa algoritma ini adalah O ( k ). Kelebihan algoritma ini ialah ia dapat disesuaikan dengan mudah untuk mempertimbangkan unsur pendua sekali sahaja .

4. Pencarian Perduaan Di Atas Kedua Susunan

Bolehkah kita melakukan yang lebih baik daripada O ( k )? Jawapannya ialah kita boleh. Idea asasnya adalah melakukan algoritma carian binari atas dua tatasusunan .

Agar ini dapat berfungsi, kita memerlukan struktur data yang menyediakan akses membaca sepanjang masa ke semua elemennya. Di Java, itu bisa berupa array atau ArrayList .

Mari tentukan kerangka kaedah yang akan kita laksanakan:

int findKthElement(int k, int[] list1, int[] list2) throws NoSuchElementException, IllegalArgumentException { // check input (see below) // handle special cases (see below) // binary search (see below) }

Here, we pass k and the two arrays as arguments. First, we'll validate the input; second, we handle some special cases and then do the binary search. In the next three sections, we'll look at these three steps in reverse order, so first, we'll see the binary search, second, the special cases, and finally, the parameter validation.

4.1. The Binary Search

The standard binary search, where we are looking for a specific element, has two possible outcomes: either we find the element we're looking for and the search is successful, or we don't find it and the search is unsuccessful. This is different in our case, where we want to find the kth smallest element. Here, we always have a result.

Let's look at how to implement that.

4.1.1. Finding the Correct Number of Elements From Both Arrays

We start our search with a certain number of elements from the first array. Let's call that number nElementsList1. As we need k elements in total, the number nElementsList1 is:

int nElementsList2 = k - nElementsList1; 

As an example, let's say k = 8. We start with four elements from the first array and four elements from the second array (a).

If the 4th element in the first array is bigger than the 4th element in the second array, we know that we took too many elements from the first array and can decrease nElementsList1 (b). Otherwise, we know that we took too few elements and can increase nElementsList1 (b').

We continue until we have reached the stopping criteria. Before we look at what that is, let's look at the code for what we've described so far:

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. Stopping Criteria

We can stop in two cases. First, we can stop if the maximum element we take from the first array is equal to the maximum element we take from the second (c). In this case, we can simply return that element.

Second, we can stop if the following two conditions are met (d):

  • The largest element to take from the first array is smaller than the smallest element we do not take from the second array (11 < 100).
  • The largest element to take from the second array is smaller than the smallest element we do not take from the first array (21 < 27).

It's easy to visualize (d') why that condition works: all elements we take from the two arrays are surely smaller than any other element in the two arrays.

Here's the code for the stopping criteria:

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. The Return Value

Finally, we need to return the correct value. Here, we have three possible cases:

  • We take no elements from the second array, thus the target value is in the first array (e)
  • The target value is in the first array (e')
  • The target value is in the second array (eā€)

Let's see this in code:

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

Note that we do not need to handle the case where we don't take any element from the first array ā€” we'll exclude that case in the handling of special cases later.

4.2. Initial Values for the Left and Right Borders

Until now, we initialized the right and left border for the first array with k and 0:

int right = k; int left = 0;

However, depending on the value of k, we need to adapt these borders.

First, if k exceeds the length of the first array, we need to take the last element as the right border. The reason for this is quite straightforward, as we cannot take more elements from the array than there are.

Second, if k is bigger than the number of elements in the second array, we know for sure that we need to take at least (k ā€“ length(list2)) from the first array. As an example, let's say k = 7. As the second array only has four elements, we know that we need to take at least 3 elements from the first array, so we can set L to 2:

Here's the code for the adapted left and right borders:

// 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. Handling of Special Cases

Before we do the actual binary search, we can handle a few special cases to make the algorithm slightly less complicated and avoid exceptions. Here's the code with explanations in the comments:

// 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. Input Validation

Let's look at the input validation first. To prevent the algorithm from failing and throwing, for example, a NullPointerException or ArrayIndexOutOfBoundsException, we want to make sure that the three parameters meet the following conditions:

  • Both arrays must not be null and have at least one element
  • k must be >= 0 and cannot be bigger than the length of the two arrays together

Here's our validation in code:

void checkInput(int k, int[] list1, int[] list2) throws NoSuchElementException, IllegalArgumentException { if(list1 == null || list2 == null || k  list1.length + list2.length) { throw new NoSuchElementException(); } }

4.5. Full Code

Here's the full code of the algorithm we've just described:

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  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. Testing the Algorithm

In our GitHub repository, there are many test cases that cover a lot of possible input arrays and also many corner cases.

Here, we only point out one of the tests, which tests not against static input arrays but compares the result of our double binary search algorithm to the result of the simple join-and-sort algorithm. The input consists of two randomized arrays:

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

The following method performs one single test:

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); }

And we can call the above method to run a large number of tests like that:

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

6. Conclusion

In this article, we saw several ways of how to find the kth smallest element in the union of two sorted arrays. First, we saw a simple and straightforward O(n log n) algorithm, then a version with complexity O(n), and last, an algorithm that runs in O(log n).

Algoritma terakhir yang kami lihat adalah latihan teori yang bagus; namun, untuk kebanyakan tujuan praktikal, kita harus mempertimbangkan untuk menggunakan salah satu daripada dua algoritma pertama, yang jauh lebih mudah daripada carian binari atas dua tatasusunan. Sudah tentu, jika prestasi menjadi masalah, carian binari boleh menjadi penyelesaian.

Semua kod dalam artikel ini terdapat di GitHub.