[C 기반 정렬 알고리즘] 교환 정렬의 빠른 정렬

머리말

이 글은 C언어를 기반으로 정렬 알고리즘의 교환 정렬에 대한 저자의 학습 경험과 경험의 물결을 공유하기 위한 것입니다.

빠른 정렬

Quick Sorting은 1962년 Hoare가 제안한 이진 트리 구조 교환 정렬 방법입니다. 기본 아이디어는 정렬할 요소 시퀀스의 모든 요소를 ​​기준 값으로 취하고 정렬할 집합을 두 부분으로 나눕니다. 정렬 코드 시퀀스에 따라 왼쪽 서브 시퀀스의 모든 요소가 기준 값보다 작고 오른쪽 서브 시퀀스의 모든 요소가 기준 값보다 큰 다음 가장 왼쪽 서브 시퀀스의 모든 요소가 해당 위치에 정렬될 때까지 프로세스를 반복합니다. 위치.

// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
    
    
    if(right - left <= 1)
    	return;
    // 按照基准值对array数组的 [left, right)区间中的元素进行划分
    int div = partion(array, left, right);
    // 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
    // 递归排[left, div)
    QuickSort(array, left, div);
    // 递归排[div+1, right)
    QuickSort(array, div+1, right);
}

위는 퀵 정렬 재귀 구현을 위한 주요 프레임워크로 이진 트리의 선순 순회 규칙과 매우 유사함을 알 수 있다. 빠르게 쓸 수 있습니다. 기준 값을 사용하여 간격을 맞추는 방법을 분석하기만 하면 됩니다. 데이터를 나누는 방법은 다음과 같습니다.

기준값에 따라 구간을 좌우 반으로 나누는 방법은 크게 3가지가 있습니다.

(다음은 모두 오름차순으로 생각됩니다)

1. 호어 버전

기본 설명

키 값을 선택한 후 L과 R은 각각 배열의 헤드와 테일에서 시작하여 가운데로 이동하게 하고, L은 키보다 큰 값을, R은 키보다 작은 값을 찾습니다.

쉰

회의 위치 값이 키 값보다 작은지 확인하는 방법은 무엇입니까?

L과 R이 멈추는 방법에 대해 이야기하겠습니다. 더 큰 값/작은 값을 만나거나 L과 R이 만나는 것입니다.

왼쪽의 첫 번째 요소를 키로 사용하고 오른쪽의 R을 먼저 놓으십시오(L과 R은 동시에 시작하지 않으며 하나가 먼저 와야 합니다).

두 가지 상황이 있습니다.

  1. R은 작은 값을 만났을 때 멈추고, L은 걸었고, L은 더 큰 값을 찾지 못하고 R을 만났습니다. 이때 만나는 위치의 값은 키보다 작습니다.
  2. L이 더 큰 값을 만나 멈추면 R과 L 위치의 값이 바뀌게 되는데, 다음 라운드 시작 시 R이 먼저 가고 이 때에도 L은 여전히 ​​멈춘다는 점에 유의하세요(멈춘 값은 이전 교환에서 위치가 변경되었습니다. 더 작은 값), R은 더 작은 값을 찾지 못하고 L을 만났고, 만나는 위치의 값은 키보다 작습니다.

이미지-20220907191655034

이미지-20220907191709766

마찬가지로 오른쪽의 첫 번째 요소를 키로 사용하려면 왼쪽의 L을 먼저 가도록 하여 만나는 위치의 값이 키의 값보다 크도록 해야 합니다.

암호

배열 요소를 교환하고 싶기 때문에 키가 임시 변수인 경우 키와 배열 요소를 교환할 수 없으므로 첨자 keyi를 사용하여 교환할 수 있습니다.

외부 while 조건뿐만 left < right아니라 내부 두 while 조건도 왜? L과 R을 찾을 때 누락을 방지하기 위해 L과 R이 일치하는지 판단 조건으로도 사용됩니다. 동시에 >=와 <=에도 주의를 기울여야 합니다. 동일한 상황을 필터링해야 합니다. 왜냐하면 우리가 찾고 있는 것은 더 큰 값과 더 작은 값이기 때문입니다. 동일한 값을 만났을 때 멈추면 간섭을 일으키게 됩니다.

void Swap(int* px, int* py)
{
    
    
    int tmp = *px;
    *px = *py;
    *py = tmp;
}

int PartSort_1(int* arr, int left, int right)
{
    
    
 	assert(arr);
    
    int keyi = left;
    while(left < right)
    {
    
    
        while(left < right && arr[right] >= arr[keyi])
            --right;
        while(left < right && arr[left] <= arr[keyi])
            ++left;
        if(left < right)//不是因为相遇而停下来才交换,若是相遇就要出循环
            Swap(&arr[left], &arr[right]);
    }
    
    int meeti =  left;
    Swap(&arr[meeti], &arr[keyi]);
    
    return meeti;//返回相遇位置的下标是为了后续分割子区间
}

중요성

이건 그냥 단방향 정렬일 뿐인데, 요점이 뭐야?

  1. 키에 해당하는 값이 정렬되었습니다(값이 정렬됨).
  2. 동시에 키를 기준으로 왼쪽과 오른쪽 하위 음정이 나뉩니다. 하위 구간이 순서대로 있으면 전체가 순서대로 됩니다. 여기에는 하위 문제의 재귀가 포함됩니다.

재귀 구현

사실 좌우 부구간을 나눈 ​​후 재귀를 이용하여 문제를 해결하는데 이러한 재귀 과정을 이진트리로 표현하면 다음과 같다.

이미지-20220907202017301

meeti는 L과 R이 매번 만나는 위치의 첨자이고, 좌우 부구간은 [left,meeti-1]과 [meeti+1,right]이다.재귀적 종료조건을 놓치지 않도록 주의한다. : 왼쪽 if(left >= right)return;경계가 오른쪽 경계보다 크거나 같으면 간격이 빈 간격이거나 요소가 하나만 있는 경우 재귀를 계속하지 않아야 하며 이때 이전 함수를 반환해야 합니다.

암호

void QuickSort(int* arr, int left, int right)
{
    
    
    assert(arr);
    
    if(left >= right)
        return;
    
    int meeti = PartSort_1(arr, left, right);
    
    QuickSort(arr, left, meeti - 1);
    QuickSort(arr, meeti + 1, right);
}

이미지-20220907211052767

시간 복잡도: O(nlogn)

공간 복잡도: O(logn)

안정성: 불안정

퀵 정렬의 전체적인 종합적인 성능과 사용 시나리오는 상대적으로 좋기 때문에 감히 정렬이라고 할 수 있습니다.

부족

질서를 만나거나 질서에 가까워지면 끝난다.

현재 최소값 또는 최대값에 가까운 키의 가장 왼쪽 또는 가장 오른쪽 값을 선택하기 때문에 간격 분할 및 재귀 중에 "일방적인" 상황이 발생합니다(키의 왼쪽이 찾을 수 없기 때문입니다. 키보다 큰 값, 오른쪽의 키보다 작은 키는 없음) 너무 깊은 재귀 레이어와 쉽게 스택 오버플로가 발생하고 시간 복잡도가 O(n 2 )가 되며 효율성이 갑자기 낮아 집니다 .

이미지-20220907211327444

핵심 선택 아이디어의 최적화(세 숫자 중 가운데를 차지)

그렇다면 이 정렬된 또는 근접 정렬된 상황에 대해 키 선택 논리를 최적화할 수 있습니까?

세 개의 숫자 중 가운데를 취하여, 즉 첫 번째 위치, 중간 위치, 마지막 위치의 값 중에서 중간 크기의 값을 선택하고 중간 값을 앞으로 변경하고 정렬을 계속합니다 . 이러한 방식으로 배열의 최대값 또는 최소값은 어떤 방식으로든 선택되지 않으므로 주문 또는 주문에 근접하여 발생할 수 있는 나쁜 결과를 효과적으로 피할 수 있습니다.

세 개의 숫자를 비교하는 함수를 작성하고 중간 값의 첨자를 반환한 다음 중간 값을 첫 번째 값으로 교환합니다.

int GetMidIndex(int* arr, int left, int right)
{
    
    
    int mid = (right - left) / 2 + left;
    if(arr[left] > arr[mid])
    {
    
    
        if(arr[mid] > arr[right])
            return mid;
        else if(arr[right] < arr[left])
            return right;
        else
            return left;
    }
    else
    {
    
    
        if(arr[left] > arr[right])
        	return left;
        else if(arr[mid] > arr[right])
            return right;
        else
            return mid;
    }
    
}

int PartSort_1(int* arr, int left, int right)
{
    
    
 	assert(arr);
    int mid = GetMidIndex(arr, left, right);
    Swap(&arr[left], &arr[mid]);
    int keyi = left;
    while(left < right)
    {
    
    
        while(left < right && arr[right] >= arr[keyi])
            --right;
        while(left < right && arr[left] <= arr[keyi])
            ++left;
        if(left < right)//不是因为相遇而停下来才交换,若是相遇就要出循环
            Swap(&arr[left], &arr[right]);
    }
    
    int meeti =  left;
    Swap(&arr[meeti], &arr[keyi]);
    
    return meeti;//返回相遇位置的下标是为了后续分割子区间
}

셀 간 최적화

작은 하위 구간으로 재귀할 때 재귀 이진 트리에 많은 노드가 있으므로 재귀의 양을 줄이기 위해 작은 구간에 대해 직접 삽입 정렬을 사용하는 것을 고려할 수 있습니다.

이진 트리의 특성상 하위 1층, 2층, 3층의 노드 수가 요약 포인트의 거의 87.5%를 차지합니다. 각 노드는 재귀입니다. 데이터 양이 많을 때, 이 세 레이어의 재귀는 매우 크고 이 세 레이어의 간격은 기본적으로 작은 간격이며 직접 정렬 비용은 몇 개의 숫자에 대한 재귀보다 낮습니다. 여기에서는 작은 영역 사이의 요소 수에 대한 기준으로 8을 사용합니다. 작은 영역 사이에 있는 한 재귀를 포기하고 직접 삽입 정렬을 사용합니다.

void InsertSort(int* arr, int sz)
{
    
    
    assert(arr);
    
    for(int i = 0; i < sz - 1; ++i)
    {
    
    
        int end = i;
        int tmp = arr[end + 1];
        while(end >= 0)
        {
    
    
            if(arr[end] > tmp)
            {
    
    
                arr[end + 1] = arr[end];
                --end;
            }
            else
                break;

        }
        arr[end + 1] = tmp;        
    }
}

void QuickSort(int* arr, int left, int right)
{
    
    
    assert(arr);
    
    if(left >= right)
        return;
    
    if(right - left + 1 <= 8)
    {
    
    
        InsertSort(arr + left, right - left + 1);
    }
    else
    {
    
    
        int meeti = PartSort_1(arr, left, right);
        QuickSort(arr, left, meeti - 1);
        QuickSort(arr, meeti + 1, right);
    }

}

상기시키다

한 가지 말씀드리자면 이 버전에서는 좀 더 주의해야 할 사항이 있고, 미숙련자가 버그를 쓰기 쉬우므로 사용을 권장하지 않습니다.

2. 굴착 방법

이것은 hoare의 개작판으로 일반적인 논리적 틀에는 차이가 없으며, 주된 차이점은 일부 디테일 구현의 차이로 사람들이 더 잘 이해하고 받아들일 수 있습니다.

싱글 패스 정렬의 아이디어에서는 여전히 가장 왼쪽 값을 키로 사용하지만 L은 더 큰 값을 찾고 R은 더 작은 값을 찾지만 찾은 두 값을 교환하는 것은 아닙니다. L과 R로 "구멍을 채우십시오" ". 처음에는 맨 왼쪽 값을 임시로 키에 저장하고 이때 "피트 위치"를 형성한 다음 R을 먼저 놓아 더 작은 값을 찾으면 이 값을 "피트에 채움" 작은 값이 있는 위치가 "구멍 파기" 맞죠? L과 R이 만나기 전까지는 이때의 만남의 위치도 "피트 위치"이고 키의 값이 "피트에 채워짐"으로 정렬이 완료됩니다.

[외부 링크 사진 전송 실패, 소스 사이트에 거머리 방지 메커니즘이 있을 수 있으므로 사진을 저장하고 직접 업로드하는 것이 좋습니다(img-JB5ZhfaD-1662793819275)(https://typora-picture-1313051246.cos.ap -beijing.myqcloud.com /Digging method.gif)]

int PartSort_2(int* arr, int left, int right)
{
    
    
    assert(arr);
    int mid = GetMidIndex(arr, left, right);
    Swap(&arr[left], &arr[mid]);
    int key = arr[left];
    int hole = left;

    while (left < right)
    {
    
    
        while (left < right && arr[right] >= key)
            --right;
        if (left < right)
        {
    
    
            arr[hole] = arr[right];
            hole = right;
        }
        while (left < right && arr[left] <= key)
            ++left;
        if (left < right)
        {
    
    
            arr[hole] = arr[left];
            hole = left;
        }

    }

    arr[hole] = key;
    return hole;
}

다른 내용은 그대로지만, 싱글 패스 정렬의 개념이 약간 바뀌었습니다.

void QuickSort(int* arr, int left, int right)
{
    
    
    assert(arr);
    
    if(left >= right)
        return;
    
    if(right - left <= 8)
    {
    
    
        InsertSort(arr + left, right - left + 1);
    }
    else
    {
    
    
        int meeti = PartSort_2(arr, left, right);
        QuickSort(arr, left, meeti - 1);
        QuickSort(arr, meeti + 1, right);
    }

}

3. 전후 포인터 방식

이것도 hoare의 개작판인데, 일반적인 논리적 틀에는 차이가 없는데, 주로 일부 디테일 구현의 차이 때문이다.

싱글 패스 정렬의 개념으로 prev와 cur 두 포인터를 설정하고, 배열의 왼쪽에서 오른쪽으로 걸어가며, cur이 먼저 가고, 더 작은 값을 만나면 cur이 멈추고, prev가 한 걸음 물러서서 스왑한다. 이 시간 값에서 prev와 cur의 위치, 그리고 cur가 배열 경계를 벗어날 때까지 cur은 계속 걷고 마지막으로 keyi 위치의 값을 prev 위치의 값으로 교환합니다.

[외부 링크 사진 전송 실패, 원본 사이트에 도난 방지 링크 메커니즘이 있을 수 있으므로 사진을 저장하고 직접 업로드하는 것이 좋습니다. ap-beijing.myqcloud.com / 포인터 전후.gif)]

int PartSort_3(int* arr, int left, int right)
{
    
    
    assert(arr);
    int mid = GetMidIndex(arr, left, right);
    Swap(&arr[left], &arr[mid]);
    int keyi = left;
    int prev = left;
    int cur = left + 1;

    while (cur <= right)
    {
    
    
        if (arr[cur] < arr[keyi])
        {
    
    
            ++prev;
            Swap(&arr[prev], &arr[cur]);
        }

        ++cur;
    }

    Swap(&arr[prev], &arr[keyi]);
    return prev;
}

또한 약간의 최적화가 있을 수 있습니다: prev와 cur이 같은 위치에 있으면 교환이 의미가 없습니다. 더 많은 판단을 내리고 이러한 교환을 생략할 수 있습니다.

int PartSort_3(int* arr, int left, int right)
{
    
    
    assert(arr);
    int mid = GetMidIndex(arr, left, right);
    Swap(&arr[left], &arr[mid]);
    int keyi = left;
    int prev = left;
    int cur = left + 1;
    
    while(cur <= right)
    {
    
    
        if(arr[cur] < arr[keyi] && ++prev != cur)
        	Swap(&arr[prev], &arr[cur]);   
        
        ++cur;
    }
    
    Swap(&arr[keyi], &arr[prev]);
    return prev;
}

다른 내용은 그대로지만, 싱글 패스 정렬의 개념이 약간 바뀌었습니다.

void QuickSort(int* arr, int left, int right)
{
    
    
    assert(arr);
    
	if(left >= right)
    	return;

	if(right - left <= 8)
	{
    
    
    	InsertSort(arr + left, right - left + 1);
	}
    else
    {
    
    
        int meeti = PartSort_3(arr, left, right);
        QuickSort(arr, left, meeti - 1);
        QuickSort(arr, meeti + 1, right);
    }
}

비재귀적 구현

재귀적 구현을 ​​사용하면 데이터 양이 많을 때 너무 깊은 재귀로 인해 스택 오버플로가 발생할 수 있는데, 이를 극복하기 위해 비재귀적 구현을 ​​고려할 수 있으며 비재귀적 구현은 재귀적 구현에 대한 깊은 이해가 필요하다.

사실 재귀를 시뮬레이트하기 위해서는 루프를 사용해야 하는데 여기서는 구간의 좌우 경계에 접근하기 위해 스택을 사용한다. 왼쪽 및 오른쪽 하위 간격의 분할 지점 왼쪽 및 오른쪽 하위 간격의 왼쪽 및 오른쪽 경계를 가져오는 지점입니다.

스택에 밀어넣는 것은 먼저 왼쪽, 그 다음 오른쪽 순서이고, 팝핑은 오른쪽 먼저, 그 다음 왼쪽 순서입니다. 원래 배열의 왼쪽 및 오른쪽 경계를 처음에 스택에 밀어넣고 루프에 들어간 후 스택이 비어 있지 않은 한 계속합니다. 본질적으로 스택의 선입선출 방식을 사용하여 오른쪽 부분을 먼저 넣고 왼쪽 부분을 가져옵니다. 가져올 때는 왼쪽 부분을 먼저 꺼내고 왼쪽 부분을 PartSort한 다음 오른쪽 부분을 밀어 넣습니다. 먼저 스택을 쌓은 다음 왼쪽 섹션을 스택으로 푸시하는 식으로 한 번 가져올 때 여전히 왼쪽 간격이고, 재귀적일 때 첫 번째 왼쪽을 시뮬레이트한 다음 오른쪽을 시뮬레이트합니까?

놓치지 않도록 주의하세요 if(left >= right)continue;간격이 비어 있거나 요소가 하나만 있는 경우에는 PartSort가 필요하지 않습니다.

void QuickSortNonR(int* arr, int begin, int end)
{
    
    
    assert(arr);

    ST st;
    StackInit(&st);

    int left = begin;
    StackPush(&st, left);

    int right = end;
    StackPush(&st, right);

    while(!StackEmpty(&st))
    {
    
    
        right = StackTop(&st);
        StackPop(&st);
        left = StackTop(&st);
        StackPop(&st);

        if (left >= right)
            continue;

        int keyi = PartSort_1(arr, left, right);
        //先放右区间
        StackPush(&st, keyi + 1);
        StackPush(&st, right);
        //再放左区间
        StackPush(&st, left);
        StackPush(&st, keyi - 1);

    }

    StackDestroy(&st);
}

시청해주셔서 감사합니다 응원은 저에게 가장 큰 힘이 됩니다~

Supongo que te gusta

Origin blog.csdn.net/weixin_61561736/article/details/126796634
Recomendado
Clasificación