머리말
이번에는 병합 정렬 알고리즘을 자세히 다루지는 않고 병합 정렬 알고리즘을 사용하여 재귀에 대해 논의합니다. 병합 정렬의 특징은 연속해서 두 번의 재귀 호출을 사용한다는 점인데, 이번에는 전체 재귀 과정을 미시적인 관점에서 관찰하고 재귀의 본질을 이해해 보겠습니다. 읽어보면 분명 강해질 것입니다!
암호
먼저 코드로 바로 가보겠습니다!
using System.CodeDom.Compiler;
int _1 = 0;
int _2 = 0;
void __merge(int[] arr, int left, int mid, int right, string flag)
{
Console.WriteLine($"__merge_{
flag}: left={
left+1}, mid={
mid + 1}, right={
right + 1}");
int[] copy = new int[right - left + 1];
//copy arr[left,right] to copy[]
for (int ii = left; ii <= right; ii++)
{
copy[ii - left] = arr[ii];
}
int i = left;
int j = mid + 1;
for (int k = left; k <= right; k++)
{
if (i > mid)
{
arr[k] = copy[j-left];
j++;
}
else if (j > right)
{
arr[k] = copy[i - left];
i++;
}
else if (copy[i - left] < copy[j - left])
{
arr[k] = copy[i - left];
i++;
}
else
{
arr[k] = copy[j - left];
j++;
}
}
}
void __merge_sort(int[] arr, int left, int right, string flag)
{
if (left >= right)
return;
if (flag.Contains("1"))
{
_1 += 1;
}
if (flag.Contains("2"))
{
_2 += 1;
}
int mid = (left + right) / 2;
Console.WriteLine($"{
flag}, left={
left+1}, mid={
mid+1}, right={
right + 1}");
__merge_sort(arr, left, mid, "第1个merge_sort");
__merge_sort(arr, mid + 1, right, "第2个merge_sort");
__merge(arr, left, mid, right, flag);
}
void merge_sort(int[] arr)
{
__merge_sort(arr, 0, arr.Length - 1, "第0个merge_sort");
}
int[] arr = {
1, 3, 5, 7, 8, 2, 4, 6};
merge_sort(arr);
Console.WriteLine($"_1:{
_1}||_2:{
_2}");
foreach (var item in arr)
{
Console.Write(item + " ");
}
Console.ReadLine();
재귀 분석
이 코드의 특별한 점은 두 가지 재귀를 사용한다는 것입니다.
_1과 _2는 첫 번째와 두 번째 재귀 호출 횟수를 기록합니다.(알고리즘 로직과 무관) 여기에 추가된 플래그 매개변수는 주로 재귀 프로세스를 분석하기 위한 것입니다.
첫 번째 __merge_sort 재귀 함수는 주로 왼쪽 배열을 두 부분으로 연속적으로 나누는 것입니다.
두 번째 __merge_sort 재귀의 주요 기능은 오른쪽 배열을 두 부분으로 연속적으로 나누는 것입니다.
병합은 두 개의 분할된 배열을 크기 순서대로 하나로 결합합니다!
이 알고리즘을 구현하는 데 있어 어려운 점은 배열 경계를 재귀적으로 구성하고 파악하는 데 있습니다.
거시적으로
void __merge_sort(int[] arr, int left, int right)
{
int mid = (left + right) / 2;
__merge_sort(arr, left, mid);
__merge_sort(arr, mid + 1, right);
__merge(arr, left, mid, right, flag);
}
__merge_sort의 재귀를 통해 배열을 두 부분으로 나누고, 분할된 두 배열을 병합하는 과정입니다.
__merge를 사용하여 병합하는 전제는 병합할 두 배열이 이미 정렬된 배열이라는 것입니다!
그러나 이를 하나의 숫자로 나누면 그 숫자는 배열이고, 이 숫자도
순서가 있는 배열로 간주할 수 있습니다.
따라서 두 점이 "극단" 수준에 도달하면 __merge의 전제가 충족됩니다.
이분법이 완성된 후 Merge 작업은 다음과 같습니다.
이 그림을 보면 재귀 알고리즘을 생각하기 쉽지만 재귀 함수를 구성하는 방법은 무엇입니까? 그것은 약간 비슷합니다:
코끼리를 냉장고에 설치하려면 몇 단계가 필요합니까? 거시적 관점에서 보면 다음과 같습니다:
1 첫 번째 단계는 왼쪽으로 나뉩니다: __merge_sort(arr, left, mid);
2 두 번째 단계는 오른쪽으로 나뉩니다: __merge_sort(arr, mid + 1, right );
3 세 번째 단계는 함께 통합됩니다: __merge(arr, left, mid, right, flag);
미시적인 관점에서
먼저 전체 재귀 프로세스가 어떻게 실행되는지 미시적인 관점에서 살펴보겠습니다. (다음 두 그림을 참고하세요.)
이것은 프로그램의 실행 결과이고, 0번째는 가장 바깥쪽의 __merge_sort가 호출되었음을 나타냅니다.
이때 가장 왼쪽이 1, 가운데가 4, 가장 오른쪽이 8이다
. 그러면 __merge_sort의 재귀 호출이 트리거되고 첫 번째 __merge_sort가 왼쪽을 담당한다.
따라서 가장 왼쪽이 1, 가운데가 2, 가장 오른쪽이 4입니다. 이때 재귀 종료 조건이 충족되지 않으므로
첫 번째 __merge_sort가 계속 호출됩니다. 이때 계속 왼쪽 부분을 담당합니다(1 2 3 4의 왼쪽 부분임을 참고하세요).
따라서 1 1 2가 있고 다음 번에 재귀가 발생할 때 왼쪽이 오른쪽과 동일하므로(왼쪽 >= 오른쪽) 다음 번에 재귀 종료 조건이 충족될 것임이 분명합니다.
다음 단락이 핵심입니다.
그럼 다음번에는 두 번째 재귀 호출이 시작됩니다! 그는 오른쪽의 두 지점을 담당합니다. 여기서 이상하게 생각하시는 분들도 계시겠지만, 오른쪽 통화에 책임이 있는 것 아닌가요? 왜 3 3 4가 인쇄되나요? 이것은 왼쪽입니다!
이것이 제가 이해하는 바입니다. 재귀는 여러 수준으로 구분됩니다. 각 재귀 수준은 계단을 내려가는 것과 같습니다. 재귀가 돌아올 때마다 한 단계 올라갑니다. 방금 종료했을 때 실제로는 두 지점에 있었습니다. 1 2 3 4 이 층의 사다리이므로 이때 전체 층에서 둘로 나누어야 할 것은 1 2 3 4의 오른쪽! 따라서 이분법은 3 3 4입니다.
이때 이 레이어의 __merge_sort도 이전 레이어로 돌아갑니다.
이때 출력되는 것은 5 6 8이고, 직접 나눗셈은 오른쪽이 5 6 7 8인데, 이는 이전 레이어 왼쪽의 1 2 3 4가 마지막 재귀에서 나누어졌기 때문입니다! (재귀의 각 수준에는 고유한 메모리가 있습니다. 실제로 각 수준의 매개변수는 저장을 위해 스택에 푸시됩니다.) 이때 우리는 재귀의 최상위 수준에 도달했으며 첫 번째 수준의 왼쪽과 오른쪽에 도달했습니다. 레벨이 나누어졌습니다.
다음 단계는 다음 단계로 재귀를 이어가는 것인데, 왼쪽의 1 2 3 4가 2개로 나누어졌으니 오른쪽도 5 6 7 8이 되고, 5 6 7 8도 56
| 78. 따라서 첫 번째 __merge_sort는 왼쪽의 56을 이등분하기 시작합니다.
그래서 이때 출력되는 것은 5 5 6이고, 마지막으로 오른쪽 두 번째 것이 7 7 8로 나누어집니다. 전체 이등분 과정은 끝났습니다.
두 개의 __merge_sort는 항상 같은 레벨에 있다는 점에 유의해야 합니다. 첫 번째 __merge_sort가 몇 계단 아래로 내려갈 때 두 번째 __merge_sort는 실제로 같은 수의 계단을 내려갑니다. (이에 대해서는 아래에서 자세히 설명하겠습니다)
병합된 부분
다음으로 두 분할 후 __merge 함수의 호출 프로세스를 살펴보겠습니다. 이는
완전히 예상한 대로입니다.
왼쪽을 표시하고 먼저 12를 병합한 다음 14를 병합하고 1234를 병합한 다음
오른쪽을 병합하고 먼저 56을 병합합니다. , 글쎄, 알았어, 78, 5678을 병합한 후 결과
는 148, 즉 12345678의 전체 병합입니다!
이제 재귀와 병합을 결합하여 시퀀스가 무엇인지 살펴보겠습니다.
코드 검토
int mid = (left + right) / 2; Console.WriteLine($"{ flag}, left={ left+1}, mid={ mid+1}, right={ right + 1}"); __merge_sort(arr, left, mid); __merge_sort(arr, mid + 1, right); __merge(arr, left, mid, right, flag); } ```
우선, __merge_sort의 처음 세 번의 연속 재귀 이후 첫 번째 병합이 직접 시작되었습니다!
여기서 누군가는 다음과 같이 질문할 수 있습니다. 함수 호출 순서에 따르면 두 번째 __merge_sort는 이때 실행하면 안 되나요?
__merge 함수 로 직접 전달되나요 ? 두 번째 __merge_sort가 실행되지 않습니까?
여기서 다시 한 번 레벨의 문제를 강조합니다. 이제 마지막 레벨로 다시 돌아왔습니다. 이때 왼쪽 중간 오른쪽은
1 1 2에 해당합니다. 실제로 12는 두 부분으로 나누어집니다. 이는 2번째 __merge_sort에 해당합니다. 다음과 같이 말하세요:
__merge_sort(arr, mid + 1, right);
left = mid+1 그래서 이때 재귀 종료 조건 left >= right가 충족됩니다(사실 2개만 남았으므로 그렇게 하지 마세요). 오른쪽으로 나눌 필요는 없습니다!)
따라서 이때 두 번째 __merge_sort가 호출되지 않는 것이 아니라 바로 종료됩니다. (재귀의 종료 조건도 재귀의 가장 중요한 핵심 중 하나입니다.)
따라서 __merge가 실행되어 12개의 병합이 완료됩니다(병합 프로세스는 실제로 정렬 중입니다. 상위 __merge 코드를 참조할 수 있습니다).
이때 재귀는 바닥을 치고 이전 시간으로 돌아가기 시작했으며, 이전 레벨의 왼쪽이 재귀적으로 완료되었다(12가 2개로 나누어졌는데, 이 또한 재귀 종료 조건을 만족함). 오른쪽에서 사다리가 시작되고 34가 두 개로 나누어지고( 참고: 124 주변의 모든 분할은 여기에 있습니다 ) 두 분할이 완료된 후 반환되므로
__merge를 실행하여 34의 병합을 완료합니다. 이번에는 __merge가 끝난 후 또 다른
__merge가 발생하여 1 2 4의 병합이 완료됩니다. 즉, 처음 두 개의 __merge_sort를 건너뜁니다!
왜 이런거야?
__merge가 실행된 후 재귀가 다른 수준으로 올라가기 때문인데, 이 수준에서는 실제로는 1 2 4의 두 나누기이고,
이전 재귀 과정에서는 1 2 4의 왼쪽과 오른쪽 나누기가 끝났습니다. , 그래서 합병이 즉시 시작되었습니다.
이때 남은 부분은
합병이 완료된 후 이번에는 재귀가 반환되어 재귀의 최상위 수준에 이르지만 왼쪽 부분은 실행이 완료되었으므로 오른쪽은 5 6 8의 나눗셈이다. , 나누어서 플레이한 후 두 번째 __merge_sort부터 다시 재귀에 들어갑니다(계단의 다음 단계).이 때 다음 단계의 첫 번째 __merge_sort를 만납니다 . 따라서 바닥에 도달한 5 5 6이 있으므로 돌아와서 이 수준의 두 번째 __merge_sort를 만나면 778이 됩니다. 이 시점에서 두 재귀는 모두 바닥을 치고 완료되었으며 다음 단계는 병합입니다!
여기 몇 가지 생각이 있습니다. 이것을 읽은 후에는 두 개의 재귀를 호출하는 특성을 깨달았을 것입니다. 첫 번째 재귀를 만나면 맨 아래 레이어로 재귀한 다음 다음과 같이 레이어별로 돌아갑니다. 반환 프로세스
중에 두 번째 레벨의 두 번째 __merge_sort가 호출되므로 계단을 재귀적으로 내려갈 때 첫 번째 __merge_sort가 호출되고, 계단을 올라갈 때 두 번째 재귀가 호출되며, 최상위 레벨로 올라갈 때는 두 번째 __merge_sort를 호출한 직후에 호출됩니다. , 다음 재귀 수준으로 들어가고 첫 번째 __merge_sort를 다시 만나고 다시 첫 번째 재귀 수준으로 들어갑니다! 다시 바닥을 쳤다!
시대의 문제
(재귀와 관련 없는) 또 다른 문제를 살펴보자, 배열을 10으로 확장하면,
이번에는 왼쪽을 담당하는 재귀는 5번 실행되고, 오른쪽을 담당하는 재귀는 3번만 실행된다. 이번에는 불균형인가?
이상하다고 생각하시나요?
홀수와 짝수의 문제 때문인데, 배열이 8이면 8은 2개로 나누어 4+4가 되고 최종적으로는 2+2+2+2가 된다.
단일이 될 때까지 짝수입니다. 10이면 두 포인트는 5가 됩니다. 숫자 5는 두 포인트일 때 왼쪽의 두 포인트 수가 더 많아지게 됩니다.
따라서 숫자가 8 16과 같이 2의 N승인 경우에만 배열 길이가 이 정도일 때 두 재귀에 대한 호출 횟수는 동일합니다!
재귀 요약
결국 __merge_sort가 어떻게 이등분을 달성했는지 기억할 수 있습니까?
이 과정은 매우 비밀스럽기 때문에 기억하지 못해도 문제가 되지 않지만, 재귀적 디자인의 핵심이기도 합니다.
void __merge_sort(int[] arr, int left, int right)
{
int mid = (left + right) / 2;
__merge_sort(arr, left, mid);
__merge_sort(arr, mid + 1, right);
__merge(arr, left, mid, right, flag);
}
먼저 배열을 전달하는 등의 재귀 함수를 직접 설계해야 합니다. 우리의 목적은 배열 내부 요소의 순서를 변경하는 것이지만 매번 그 일부를 고려합니다. 그래서 왼쪽과 오른쪽에 테두리가 필요합니다.
전체 배열의 경우 왼쪽은 0이고 오른쪽은 길이-1입니다.
이등분 후 왼쪽과 오른쪽은 각 이등분 후에 변경됩니다.
각 재귀 호출은 사다리를 타고 다음 단계로 들어가 왼쪽과 오른쪽이 다시 변경됩니다.
"다음 단계로의 진입"을 이해할 수 있는 것이 재귀를 이해하는 열쇠입니다. 각 재귀마다 이분법의 과정이 완성됩니다!
먼저 거시적 관점에서 아이디어를 디자인한 다음 미시적 관점에서 아이디어가 올바른지 확인할 수 있습니다.
오랫동안 이 글을 쓰고 있는데 기분이 좋네요. 여러분은 어떻게 생각하실지 모르겠습니다. 댓글란에 피드백을 환영합니다~~
첨부된 전체 Python 코드를 제공해 주세요.
원래는 이전에 테스트를 위해 Python을 사용했지만 여전히 vs를 사용하여 C#을 디버그하는 것이 편리하다는 것을 알았습니다.
def __merge(arr, left, mid, right):
arr_copy = arr[left:right + 1][:]
i = left
j = mid+1
for k in range(left, right+1):
if i > mid:
arr[k] = arr_copy[j-left]
j = j + 1
elif j > right:
arr[k] = arr_copy[i-left]
i = i + 1
elif arr_copy[i-left] < arr_copy[j-left]:
arr[k] = arr_copy[i-left]
i = i + 1
else:
arr[k] = arr_copy[j-left]
j = j + 1
def __merge_sort(arr, left, right):
if left >= right:
return
mid = (left + right) // 2
print(left, mid, right)
__merge_sort(arr, left, mid)
__merge_sort(arr, mid + 1, right)
__merge(arr, left, mid, right)
def merge_sort(arr):
__merge_sort(arr, 0, len(arr) - 1)
if __name__ == '__main__':
arr0 = [1, 3, 5, 7, 9, 2, 4, 6, 8, 10]
merge_sort(arr0)
print(arr0)