排序总览

排序是出镜率最高的面试题,基本上没有之一


一、就排序说排序

这一环节的目的,一方面是完全熟悉常用常考的排序,另一方面是对排序所涉及的相关的知识,如时间复杂度的感觉到理解、不同的数据结构、不同的算法思路、排序解决的问题、排序在实际问题中的用途,建立一个更全面的体系,毕竟不论是面试还是实际工作,所面对的问题远远不会是仅是会写排序。

1.0、排序本身都有哪些排序:

1、原地比较排序:和线性排序实质区别是,原地比较排序的时间复杂度不是O(N),在理论上最高只能做到O(N*logN),而线性排序达到O(N)线性复杂度

按核心思路分为:

1.1、基于选择的:不断选出特大/小值。

冒泡排序:稳定、接近O(N^2),从头到尾共N趟,发现有更大/小就交换

快速排序:不稳定(因为会劈开)、平均/可认为O(N*logN)、原始数据越有序则越慢(因为导致大量数据交换)最慢O(N^2)、最好情况也是O(N*logN),典型的分治思想的运用,选一个标杆值,然后把其他数据按标杆值分为两部分,就这样不断细分下去,最终实现全部有序。

  快排的分治思路非常重要,不仅排序用到,诸如找第K大的数这样的使用分治解决的问题同样会用到

冒泡排序:

/*
 *  buddle sort
 * */
void buddle_sort (int *data, int size) {
    for (int i = 0; i < size; i++) {
        for (int j = i; j < size; j++) {
            if (data[j] < data[i]) {
                int tmp = data[j];
                data[j] = data[i];
                data[i] = tmp;
            }
        }
    }
}

快速排序:

/*
 *  quick sort
 * */
int partition (int *data, int start, int end) {
    if (start < end) {
        int flagval = data[end];
        int i = start, j = start;
        for (; i < end; ++i) {
            if (data[i] < flagval) {
                int tmp = data[i];
                data[i] = data[j];
                data[j++] = tmp;
            }
        }

        int tmp = data[j];
        data[j] = flagval;
        data[end] = tmp;
        return j;
    } else {
        return -1;
    }
}

void qsort_unrecursion (int *data, int size) {
    int part = partition(data, 0, size - 1);
    std::stack<int> stk;
    stk.push(0);
    stk.push(part - 1);
    stk.push(part + 1);
    stk.push(size - 1);

    while (!stk.empty()) {
        int end = stk.top();
        stk.pop();
        int start = stk.top();
        stk.pop();

        if (start < end) {
            int part = partition(data, start, end);
            if (part > 0) {
                stk.push(start);
                stk.push(part - 1);
                stk.push(part + 1);
                stk.push(end);
            }
        }
    }
}


1.2、 基于交换的

选择排序:稳定、接近于O(N^2),从头到尾共N趟,每一趟找出第i个索引到尾部的最大/小值

堆排序:不稳定、接近于O(N*logN)(完全二叉树,无最好最坏平均之分),每趟均二叉的找出剩余数据的最大/小者作为堆顶,直到全部处理完毕

堆在海量数据面试题中常常被用到,典型为topk、中位数、第K大。实际应用中也经常用于维护满足"可能定长的、且动态可变"性质的业务结构数据。

选择排序:

/*
 *  select sort
 * */
void select_sort (int *data, int size) {
    for (int i = 0; i < size; i++) {
        int min = i;
        for (int j = i; j < size; j++) {
            if (data[min] > data[j]) {
                min = j;
            }
        }

        int tmp = data[i];
        data[i] = data[min];
        data[min] = tmp;
    }
}

堆排序:

template<class T> class Heap {
    T *data;
    int size;

public:
    Heap (T *_data, int _size):size(_size) {
        data = new T[_size + 1];
        for (int i = 1; i <= size; i++) {
            data[i] = _data[i - 1];
        }

        for (int i = size/2; i >= 1; --i) {
            adjust(i, size);
        }
    }

    ~Heap () {
        if (data) {
            delete []data;
        }
        size = 0;
    }

    void adjust (int idx, int sz) {
        if (idx <= sz/2) {
            int l = idx * 2, r = idx * 2 + 1;
            int minidx = idx;
            if (l < sz && data[minidx] < data[l]) {
                minidx = l;
            }
            if (r < sz && data[minidx] < data[r]) {
                minidx = r;
            }

            if (idx != minidx) {
                T tmp = data[idx];
                data[idx] = data[minidx];
                data[minidx] = tmp;
                adjust(minidx, sz);
            }
        }
    }

    T gettop () {
        return data[1];
    }

    void settop (T val) {
        data[1] = val;
        for (int i = size; i >= 1; i--) {
            adjust(i, size);
        }
    }

    void sort () {
        for (int i = size; i >= 1; i--) {
            T min = data[1];
            data[1] = data[i];
            data[i] = min;
            adjust(1, i - 1);
        }
    }

    void show () {
        for (int i = 1; i <= size; i++) {
            std::cout << data[i] << ",\t";
        }
        std::cout << std::endl;
    }
};

void heap_sort (int *data, int size) {
    Heap<int> hp(data, size);
    hp.sort();
    hp.show();
}

1.3、基于插入的:

插入排序:稳定、时间复杂度O(N^2),相比冒泡和选择,相对难一些:

排序方式为,每个数据在其前面的数据找比他大/小的,即所谓的插入位置,在找到之前将做数据整体移位,

如原数据为1、2、3、100、101、102、103、4

则对于4的插入排序趟,找到合适的插入位置3之后的100过程中,它前面的103到100会在遍历过程中依次向前移位,直到找到3,将4插入在原先100的位置。

明显可见如果原始数据已经比较有序,那么插入排序进行的越快。如果原始数据已经完全有序,插入排序的时间复杂度为O(N),反之则越慢,而且有大量的数据比较和交换的工作。

针对插入排序大量的数据比较工作的问题,一个常见的改进是,利用插入排序的"待排序数据之前的数据已经有序"的特点,不通过挨个比较,而是通过二分查找的方式找到待排序数据应该插入的位置,如上面的4,通过对1到103的数据进行二分查找找到应该插入的位置100;这样可大幅度减少数据比较,但仍然需要进行交换,依然是非常低效的。


所以插入排序往往用于链表,这也是链表排序面试题的解决方式之一(链表的原地排序,主要适合基于插入的和归并排序式的),链表插入排序的核心思想是分治,初始将一个元素(往往是第一个元素)作为"已排序链表",其他的元素作为"未排序链表",然后把"未排序链表"的元素依次在"已排序链表"里寻找按顺序的插入点,直到"未排序链表"的元素都插入到了"已排序链表"。

对于插入排序,链表形式之所以合适就是因为不再大量的进行数据交换,而是仅修改指针的指向。

希尔排序:不稳定、平均时间复杂度被认为接近O(N^1.3);希尔排序是"渐进式插入排序"的方式,针对插入排序适合数据越有序越快的特性,希尔排序不断用较少的排序次数,实现整个数据的较大程度有序;

希尔排序重在理解其其发明目的,是处于什么原因,对原始插入排序做的什么改进;

方式是,按由大逐渐到小的"步长",把数据按照步长的对应数据达到有序,比如总数为10,步长初始为5,那么首先保证0和5、1和6、.......4和9的有序,然后步长减少为2,保证0和2、0和2和4、0和2和4和8的有序,然后步长为1,变成插入排序,由于前面弄的数据已经相对比较有序了,所以现在做插入排序就比较快了;

二分查找优化的插入排序、链表式插入排序、希尔排序都是堆插入排序的优化改进。

二分查找优化的插入排序:

/*
 *  insert sort
 * */
int binary_search (int *data, int cur, int start, int end) {
    while (start <= end) {
        int mid = (start + end)/2;

        if (data[mid] < cur) {
            start = mid + 1;
        } else if (data[mid] > cur) {
            end = mid - 1;
        } else {
            return mid + 1;
        }
    }

    return start;
}

void insert_sort (int *data, int size) {
    for (int i = 1; i < size; ++i) {
        int cur = data[i], j = binary_search(data, cur, 0, i - 1);
        if (j >= 0) {
            for (int k = i; k > j; --k) {
                data[k] = data[k - 1];
            }
            data[j] = cur;
        }
    }
}


链表式插入排序:
template<class T> struct Node {
    T val;
    Node<T> *next;
    Node (T _val):val(_val), next(nullptr) {}
};

template<class T> class List {
    Node<T> *root;

public:
    List (T *data, int size):root(nullptr) {
        for (int i = 0; i < size; i++) {
            if (!root) {
                root = new Node<T>(data[i]);
            } else {
                Node<T> *newnode = new Node<T>(data[i]);
                newnode->next = root->next;
                root->next = newnode;
            }
        }
    }
    ~List () {
        if (root) {
            while (root) {
                Node<T> *next = root->next;
                delete root;
                root = next;
            }
            root = nullptr;
        }
    }

    void sort () {
        Node<T> *sorted = root, *unsorted = root->next;
        sorted->next = nullptr;
        while (unsorted) {
            Node<T> *p = sorted, *prev = sorted, *q = unsorted;
            for (; p && q->val >= p->val; p = p->next) {
                prev = p;
            }
            Node<T> *next = q->next;
            unsorted = next;
            if (p == sorted) {
                q->next = sorted;
                sorted = q;
            } else {
                q->next = p;
                prev->next = q;
            }
        }
        root = sorted;
    }

    void show () {
        Node<T> *cur = root;
        while (cur) {
            std::cout << cur->val << ",\t";
            cur = cur->next;
        }
        std::cout << std::endl;
    }
};

void insert_list_sort (int *data, int size) {
    List<int> list(data, size);
    list.show();
    list.sort();
    list.show();
}

希尔排序:

void shell_sort (int *data, int size) {
    for (int i = size/2; i > 0; i /= 2) {
        for (int j = i; j < size; j++) {
            int cur = data[j];

            int k = j;
            while (k >= 0 && cur <= data[k - i]) {
                data[k] = data[k - i];
                k -= i;
            }

            if (k < 0) {
                data[k + i] = cur;
            } else {
                data[k] = cur;
            }
        }
    }
}

1.4、归并排序:

归并排序从排序手段上,本身并没有什么新内容,重点在于思路:将原始数据分为不断的K分(K由2-M,M < N),当数据被分割为小于K时,做每K路的合并,并对合并后的继续做K路的合并,直到都合并完成。K路归并排序则为O(N * logN / logK)。常用的是2路归并排序,即O(N*logN)。

如数据总量为10,首先分为两个大小分别为5的部分,然后再分为两个大小为2、两个大小为3的部分,然后分成8个大小为1、2个为2的部分,这时进行merge,merge的实现自定义。

归并排序是对数复杂度排序的终极排序方法。它是稳定的、时间复杂度也稳定为O(N*logN)的。

为了实现O(N*logN)的时间复杂度,归并排序事实上需要少量(<= N)多次的空间复杂度,即merge时用线性比较的方式,将结果写入临时额外空间,再更新原数据对应部分。这个很多文章没有明说,或者用隐晦的方式实现,表面看起来空间复杂度是O(1)。


归并排序重在思想,它是更复杂的问题,如海量数据中的各种问题中的一种重要解决思路,包括外部排序本身就是归并排序的思想出发点。

归并排序:

void sort (int *data, int st1, int ed1, int st2, int ed2) {
    int *tmp = new int[ed2 - st1 + 1];
    int start = st1;

    int i = 0;
    while (st1 <= ed1 && st2 <= ed2) {
        if (data[st1] < data[st2]) {
            tmp[i++] = data[st1++];
        } else {
            tmp[i++] = data[st2++];
        }
    }

    while (st1 <= ed1) {
        tmp[i++] = data[st1++];
    }
    while (st2 <= ed2) {
        tmp[i++] = data[st2++];
    }

    for (int idx = 0; idx < i; idx++) {
        data[start + idx] = tmp[idx];
    }
    delete []tmp;
}

void merge_sort (int *data, int start, int end) {
    if (start < end) {
        int mid = (start + end)/2;
        merge_sort(data, start, mid);
        merge_sort(data, mid + 1, end);
        sort(data, start, mid, mid + 1, end);
    }
}



1.5、总结:

1、日常:不要求稳定,快排,因为速度最快;要求稳定时建议用归并;注意堆排序的使用;

2、堆排序的妙用、快排的分治的思想、分块再归并的思路,是排序的最重要需要理解和收获的内容;要作为解决更复杂问题的常规武器;

3、要注意链表的排序,链表排序除插入方式外,最适合的就是归并方式,典型题就是"合并两个有序链表",如果可以使用额外空间,则归并排序是最佳方式;


2、线性排序:

数据总量为N,能保证也用O(N)的时间复杂度完成的排序。但往往都是有限制条件或适用场景。


1、计数排序:仅适用整数,必须明确最大值,最好从0开始。需要O(2 * N)的额外空间。

计数排序思路是:在明确所排序数字范围为0-N后,创建个数为N的数组并累计每个数字出现次数,进而得到每个数字出现的座次,如0出现2次,1出现3次,则0的座次为0-1,1的座次为2-4,以此类推得到全部数字的座次,然后是一个技巧,从后向前逆序遍历,将原始数据根据其座次,塞入结果数组;

缺点:当范围很大时,也需要一个巨大的O(range)的额外空间

计数排序:

/*
 *  counter sort, range is 0-99
 * */
void countersort (int *data, int size, int max) {
    int range[100] = {0}, result[100];

    for (int i = 0; i < size; i++) {
        range[data[i]] += 1;
    }

    for (int i = 1; i < max; i++) {
        range[i] += range[i - 1];
    }

    for (int i = size - 1; i >= 0; i--) {
        if (range[data[i]]) {
            result[range[data[i]] - 1] = data[i];
            --range[data[i]];
        }
    }

    for (int i = 0; i < size; i++) {
        data[i] = result[i];
    }
}


2、桶排序:

桶排序思路:同样需要明确最大值,然后根据数值范围,再创建一个个的小范围,如数值范围为0-100,再创建0-9、10-19、......90-99的10个小范围,每个小范围称之为一个桶,原始数据根据大小落在对应的桶中,这样首先实现了数据的按范围有序,然后每个桶内数据再排序,最终将全部桶按顺序输出就是排序结果。

另类的归并排序,在海量数据问题中很有效,比如找第K大、中位数、topk等。

缺点:和计数排序一样,当范围很大时,需要一个巨大的O(range)的额外空间

桶排序:

class Bucket {
    std::vector<int> *buckets;

public:
    Bucket (int *data, int size, int max, int base) {
        buckets = new std::vector<int>[max/base];
        for (int i = 0; i < size; i++) {
            int cur = data[i];
            int idx = cur/base;
            buckets[idx].push_back(cur);
        }
        sort(max/base);
    }

    ~Bucket () {
        delete []buckets;
    }

    void sort (int num) {
        for (int i = 0; i < num; i++) {
            std::sort(buckets[i].begin(), buckets[i].end());
        }
        for (int i = 0; i < num; i++) {
            for (auto j: buckets[i]) {
                std::cout << j << "\t";
            }
            std::cout << std::endl;
        }
    }
};

3、基数排序:基数排序是一个广义维度思路的排序方式。基数的意思是,以什么指标作为排序依据。

在用于正整数排序时,排序指标是:每一个十进制整数的末位数大小,如:

1、100、105、5000、309

第1次比较末位数:排序结果为100、5000、1、105、309

2次比较末位数:排序结果为100、5000、1、105、309

第3次比较末位数:排序结果为1、100、105、309、5000

第4次比较末位数:排序结果为1、100、105、309、5000


每次末位数的比较的排序方式随意,图简单直接用计数排序,0-9的数字范围只需要10个int的额外空间,时间复杂度O(N)

基数排序的空间复杂度为O(N + 10)相当于O(N),时间复杂度大致为O(最大数字位数 * N * 2),相当于O(N)O()

不同于计数排序和桶排序必须知道最大值确切范围,整数的基数排序只需知道最大值的位数


举一反三的思考,基数排序更大的意义在于通过不同的基数定义,进行不同的排序,如:

上亿个int32整数中找第K大/中位数。


原地比较排序中,

可以由快排的分治思想做,但快排会破坏原始数据;

可以由K大小的大顶堆实现,时间复杂度N*logN空间复杂度O(K)相当于没有;


线性排序中,计数排序也可以实现,比较有意思的解决方式是:

1、桶排序:分为若干桶,O(N)的插入不同的桶,插入后可发现每个桶内数据大小,则可定位第K大、中位数落在哪个桶的哪个位置,

然后对那个桶单独进行排序,取到那个位置的数据,使用额外空间可认为O(N),时间复杂度可认为O(N)

2、基数排序:非上面的直接基数排序,而是同时借鉴直接基数排序和通排序,改排序指标即基数为每个数除以65535的余数,

这样对于int32的整数,会有最多65536个结果,即0-65535、65536-65536*2、...这样,

创建数组个数为65536,遍历一次数据,每个成员统计落到本区间数字的个数

这样,遍历一次后,同样可以根据每个区间的个数,判断出第K大/中位数在哪个区间及其位置,

然后再次遍历原始数据,专门找出落在对应区间的数据,进而找到该数据。

空间复杂度大幅度降低为O(65535),因为每个区间仅仅计数,这点区别于桶排序方式,时间复杂度O(N * 2)相当于O(N)


当K比较大,比如上亿数找中位数时,基数排序的优势就体现出来了,相比其他任何方式,空间相对小速度相对快


基数排序:

int get_max_bit_num (int max) {
    int num = 0;
    while (max) {
        ++num;
        max /= 10;
    }

    return num;
}

void radixsort (int *data, int size, int max) {
    int maxbit = get_max_bit_num(max), base = 1;
    int range[10] = {0};
    int result[size];

    for (int i = 0; i < maxbit; i++) {
        for (int j = 0; j < 10; j++) {
            range[j] = 0;
        }

        for (int j = 0; j < size; j++) {
            int lastbit = (data[j]/(base)) % 10;
            ++range[lastbit];
        }

        for (int j = 1; j < 10; j++) {
            range[j] += range[j - 1];
        }

        for (int j = size - 1; j >= 0; j--) {
            result[range[(data[j]/(base)) % 10] - 1] = data[j];
            --range[(data[j]/(base)) % 10];
        }

        for (int j = 0; j < size; j++) {
            data[j] = result[j];
        }

        base *= 10;
    }
}


二、更广义的排序:

1、hash族的bitmap,也是一个排序利器,需要的额外空间还更小,同样需要预先知道最大值,不过最好非负整数,并且最好不要重复;

限制条件较多,所以只在某些条件下适用。

时间复杂度O(N * 2)相当于O(N),O(N/32 + 1)的额外空间,亮点是空间复杂度降低,对于不是特别巨大的海量数据有效。


bitmap排序:

class Bitmap {                                                                             
    int *data;                                                                             
                                                                                           
public:                                                                                    
    Bitmap (int max) {                                                                     
        data = new int[max/32 + 1];                                                        
    }                                                                                      
                                                                                           
    ~Bitmap () {                                                                           
        delete []data;                                                                     
    }                                                                                      
                                                                                           
    void Set (int d) {                                                                     
        data[d / 32] |= (1 << (d % 32));                                                   
    }                                                                                      
                                                                                           
    void Traverse (int max) {                                                              
        for (int i = 0; i < max/32 + 1; i++) {                                             
            for (int j = 0; j < 32; j++) {                                                 
                if (data[i] & (1 << j)) {                                                  
                    std::cout << i * 32 + j << " ";                                        
                }                                                                          
            }                                                                              
        }                                                                                  
                                                                                           
        std::cout << std::endl;                                                            
    }                                                                                      
};

2、可排序的数据结构

对于实际的有序数据存储,肯定不是一个已知数组,而是一个数据结构,能够动态的增加/更新、删除成员,同时保持有序性,

排序的用途,最终归结在"可随时快速的增删查改"


对于可排序的数据结构,分为两大类:

1、树族:无翻转规则的二叉排序树、按翻转规则的二叉排序树、按分裂规则的多叉排序树

1.1、无翻转规则的二叉排序树:无实际用途

1.2、按翻转规则的二叉排序树:典型为AVL树、红黑树,其中红黑树用于标准库的map

对于这一类,明确为什么要翻转规则即可。不翻转放任自流,就像第一种,那么增删查改时间复杂度无法控制,极端情况下退化为有序链表

AVL的翻转较简单,任何一个节点底下的左右子树高度差不可超过1,超过1就翻转。所以AVL树的增删查改的时间复杂度肯定是O(logN),

但缺点是总会引起翻转;而红黑树则一定程度上放宽了高度差要求,减少了翻转次数,增删查改的时间复杂度可认为也是O(logN)。

1.3、按分裂规则的多叉排序树:B族树,典型的被用于mysql

2、链表族:目前所了解的只有跳表

跳表

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值