diff --git a/Exercise_1.cpp b/Exercise_1.cpp index a6dc14cc..98daf307 100644 --- a/Exercise_1.cpp +++ b/Exercise_1.cpp @@ -1,19 +1,68 @@ -#include - +// TC: O(log n), since we reduce the search space by half each time. So for an array of size 8, we would call the function 4 times in the worst +// case: 8 -> 4 -> 2 -> 1. log(8) with base 2 = 3 (roughly equal to 4). Therefore, the TC is O(log n). +// SC: O(log n), since there are log n recursive calls, the recursion stack will have size O(log n). We can optimize this to O(1) space complexity +// by using an iterative approach. +// Explanation in Sample.java + +#include +using namespace std; + // A recursive binary search function. It returns // location of x in given array arr[l..r] is present, // otherwise -1 int binarySearch(int arr[], int l, int r, int x) { //Your Code here + if(l>r) return -1; + int mid = l + (r-l)/2; // to avoid integer overflow + if(arr[mid] == x) return mid; // found the search element + else if(arr[mid] > x) return binarySearch(arr, l, mid-1, x); // element possibly exists in the left half + else return binarySearch(arr, mid+1, r, x); // element possibly exists in the right half } - + +// iterative approach +int binarySearchIter(int arr[], int l, int r, int x) { + while(l<=r) { + int mid = l + (r-l)/2; + if(arr[mid] == x){ + return mid; + } else if(arr[mid] < x) { + l = mid + 1; + } else{ + r = mid - 1; + } + } + return -1; +} + +bool isSorted(int A[], int size) { + for(int i=1; i A[i]) return false; + } + return true; +} + int main(void) { - int arr[] = { 2, 3, 4, 10, 40 }; + /* TEST CASES */ + int arr[] = { 2, 3, 4, 10, 40 }; int x = 15; // not present + // int arr[] = {5}; int x = 4; // single element + // int arr[] = {1, 3, 2, 5, 0}; int x = 3; // unsorted + // int arr[] = {}; int x = 5; // empty + // int arr[] = {2, 2, 2, 2, 2}; int x = 2; // all duplicates + // int arr[] = {-10, -3, 0, 1, 8}; int x = -3; // negative + // int arr[] = {INT_MIN, -1, 0, 1, INT_MAX}; int x = INT_MIN; // extreme values + int n = sizeof(arr) / sizeof(arr[0]); - int x = 10; - int result = binarySearch(arr, 0, n - 1, x); + if(n <= 0) { + cerr<<"Invalid array"< O(nlogn), Worst Case -> O(n^2) +In the best case, the pivot selection will be such that the array is split into halves each time. So if we split into half at each level, the +recursion tree will have a depth of log n. At each step the work done is n. Since we scan/swap over the entire array at each level. Even if we split +the arrays into smaller subarrays, the total work done for all subarrays on the same level = n. Therefore the best case TC is O(nlogn). +For average case, we might not get a clean half split, but we can still expect a reasonably balanced/non-skewed split. The TC for this case still +ends up about O(nlogn). + +The worst case happens when the pivot selection is bad, either the smallest or the largest element in the subarray is picked as pivot. What this does +is that the partitioned subarrays have lengths [n-1] & 0. This leads to a very skewed recursion tree (n -> n-1 -> n-2 -> n-3 ...). The depth of this +tree will be n. The work done at each level still is n. Therefore, the worst case TC becomes O(n^2). + +SC: Best/Average case: O(logn) -> There is no extra space needed as we swap in place in the same array. The space consumed is due to the recursion +stack, which is log n in the best/average case as explained above. +For the worst case, since the recursion tree is skewed and resembles a linkedlist/chain type structure, the depth = n. So the SC becomes O(n). + +Explanation in Sample.java +*/ + #include using namespace std; // A utility function to swap two elements -void swap(int* a, int* b) -{ - //Your Code here +void swap(int* a, int* b) +{ + if(*a == *b) return; + *a = *a + *b; + *b = *a - *b; + *a = *a - *b; } /* This function takes last element as pivot, places @@ -15,6 +37,22 @@ of pivot */ int partition (int arr[], int low, int high) { //Your Code here + int pivot = arr[high]; + int i = low; // iterator for the left partition + int j = high; // iterator for the right partition + while(i < j){ // to ensure that the left & right iterators don't cross each other + while(i<=high && arr[i] < pivot){ // find first element in the left partition which does not satisfy our constraints + i++; + } + while(j>=low && arr[j] >= pivot) { // find first element in the right partition which does not satisfy our constraints + j--; + } + if(i < j){ // to make sure that the swap is still valid (we don't want to accidentally swap elements which are in the correct partition) + swap(&arr[i], &arr[j]); + } + } + swap(&arr[i], &arr[high]); // the index where the left iterator spills into the right partition will be the correct position of the pivot + return i; // return index of pivot } /* The main function that implements QuickSort @@ -24,6 +62,11 @@ high --> Ending index */ void quickSort(int arr[], int low, int high) { //Your Code here + if(low < high) { // atleast 2 elements need to be present to continue, 1 element by itself is considered sorted + int pIndex = partition(arr, low, high); + quickSort(arr, low, pIndex-1); + quickSort(arr, pIndex+1, high); + } } /* Function to print an array */ @@ -34,12 +77,38 @@ void printArray(int arr[], int size) cout << arr[i] << " "; cout << endl; } + +bool isSorted(int A[], int size) { + for(int i=1; i A[i]) return false; + } + return true; +} // Driver Code int main() { - int arr[] = {10, 7, 8, 9, 1, 5}; + /* TEST CASES */ + int arr[] = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0}; + // int arr[] = {}; // empty array + // int arr[] = {1}; // array with only one element (already sorted) + // int arr[] = {1, 2, 3, 4}; // already sorted array + // int arr[] = {1, 3, 2, 1, 2, 3, 4, 1, 4, 2, 3}; // array with duplicates + // int arr[] = {5,-1,3,0,-2,4,-3}; // negatives + // int arr[] = {INT_MIN, 0, INT_MIN, INT_MAX}; // extreme values + int n = sizeof(arr) / sizeof(arr[0]); + + if(n == 0) { + cerr<<"Invalid array"< O(n) +// SC: O(1) (printMiddle() requires constant memory) +// Explanation in Sample.java #include using namespace std; + + +class LinkedList { + private: + // Struct + struct Node + { + int data; + struct Node* next; + }; + Node* head; + public: + LinkedList() { + head = nullptr; + } + ~LinkedList() { + while(head){ + Node* temp = head; + head = head->next; + delete temp; + } + } + /* Brute force to find middle of the linked list */ + void printMiddleBruteForce() { + if(!head){ + // throw exception + cerr<<"Linked list is empty!"<next){ + cout<<"Middle element: "<data<next; + len++; + } + temp = head; + for(int i=0; inext; + } + cout<<"Middle element: "<< temp->data<next){ + cout<<"Middle element: "<data<next != nullptr){ + slow = slow->next; + fast = fast->next->next; + } + cout<<"Middle element: "<< slow->data<data = new_data; - new_node->next = (*head_ref); - (*head_ref) = new_node; -} - -// A utility function to print a given linked list -void printList(struct Node *ptr) -{ - while (ptr != NULL) + // Function to add a new node + void push(int new_data) + { + Node* new_node = new Node; + new_node->data = new_data; + new_node->next = head; + head = new_node; + } + + // A utility function to print a given linked list + void printList() { - printf("%d->", ptr->data); - ptr = ptr->next; + Node* ptr = head; + while (ptr != NULL) + { + printf("%d->", ptr->data); + ptr = ptr->next; + } + printf("NULL\n"); } - printf("NULL\n"); -} +}; + + // Driver Code int main() -{ - struct Node* head = NULL; +{ /* TEST CASES */ + // LinkedList list; // empty list + // list.printList(); + // list.printMiddle(); + + LinkedList list; // normal list for (int i=15; i>0; i--) { - push(&head, i); - printList(head); - printMiddle(head); + list.push(i); } - + list.printList(); // moved printList and printMiddle out of the loop to print only when the list is completely created + list.printMiddle(); + + // LinkedList list; // single element + // list.push(12); + // list.printList(); + // list.printMiddle(); + + // LinkedList list; // odd length + // for (int i=5; i>=1; i--) { + // list.push(i); + // } + // list.printList(); + // list.printMiddle(); + + // LinkedList list; // even length + // for (int i=6; i>=1; i--) { + // list.push(i); + // } + // list.printList(); + // list.printMiddle(); + + // LinkedList list; + // for (int i=0; i<5; i++) { + // list.push(5); + // } + // list.printList(); + // list.printMiddle(); + return 0; -} \ No newline at end of file +} \ No newline at end of file diff --git a/Exercise_4.cpp b/Exercise_4.cpp index 1a528ee6..a1b20da2 100644 --- a/Exercise_4.cpp +++ b/Exercise_4.cpp @@ -1,5 +1,22 @@ -#include -#include +/* +TC: Since we are halving the array at each step, the max number of halvings till we hit a subarray of size 1 (only one element) is log n. +The work done at each level in the recursion tree = n, since we are going over each subarray and merging. + [4, 2, 1, 3] + / \ + [4, 2] [1, 3] => n elements in total need to be merged + / \ / \ + [4] [2] [1] [3] +Therefore, since there are log n levels and each level requires n work, the total TC is O(nlogn). +SC: At each level in the recursion tree, we need to create temp arrays for merging. The total space needed at each level is O(n). The recursion +stack also takes up space, since we have a recursion tree of depth log n, the recursion space will be O(logn). +Therefore total space = O(n + logn) which is asymptotically O(n). + +Explanation in Sample.java +*/ + +#include + +using namespace std; // Merges two subarrays of arr[]. // First subarray is arr[l..m] @@ -7,6 +24,40 @@ void merge(int arr[], int l, int m, int r) { //Your code here + int i = l; // left subarray iterator + int j = m+1; // right subarray iterator + int k = 0; // merged array iterator + int len = r - l + 1; // length of merged subarray + int merged[len]; + + while(i <= m && j <= r){ // merge elements in sorted order from left & right subarrays into merged + if(arr[i] <= arr[j]) { + merged[k] = arr[i]; + i++; + } else { + merged[k] = arr[j]; + j++; + } + k++; + } + + while(i <= m) { // merge leftover elements from left subarray (if any) + merged[k] = arr[i]; + i++; + k++; + } + while(j <= r) { // merge leftover elements from right subarray (if any) + merged[k] = arr[j]; + j++; + k++; + } + int x = l; + k = 0; + while(x<=r && k < len){ // change the original array to now have sorted elements from index l to r + arr[x] = merged[k]; + k++; + x++; + } } /* l is for left index and r is right index of the @@ -14,6 +65,12 @@ void merge(int arr[], int l, int m, int r) void mergeSort(int arr[], int l, int r) { //Your code here + if(l < r){ + int mid = l + (r-l)/2; + mergeSort(arr, l, mid); // call mergeSort on left half + mergeSort(arr, mid+1, r); // call mergeSort on right half + merge(arr, l, mid, r); // merge the left and right halves + } } /* UTILITY FUNCTIONS */ @@ -25,15 +82,39 @@ void printArray(int A[], int size) printf("%d ", A[i]); printf("\n"); } + +bool isSorted(int A[], int size) { + for(int i=1; i A[i]) return false; + } + return true; +} /* Driver program to test above functions */ int main() { - int arr[] = {12, 11, 13, 5, 6, 7}; + /* TEST CASES */ + int arr[] = {12, 11, 13, 5, 6, 7}; // normal array + // int arr[] = {}; // empty array + // int arr[] = {1}; // array with only one element (already sorted) + // int arr[] = {1, 2, 3, 4}; // already sorted array + // int arr[] = {1, 3, 2, 1, 2, 3, 4, 1, 4, 2, 3}; // array with duplicates + // int arr[] = {5,-1,3,0,-2,4,-3}; // negatives + // int arr[] = {INT_MIN, 0, INT_MIN, INT_MAX}; // extreme values + int arr_size = sizeof(arr)/sizeof(arr[0]); - + if(arr_size == 0) { + cerr<<"Invalid array"< using namespace std; // A utility function to swap two elements void swap(int* a, int* b) { - int t = *a; - *a = *b; - *b = t; + if(*a == *b) return; + *a = *a + *b; + *b = *a - *b; + *a = *a - *b; } /* This function is same in both iterative and recursive*/ -int partition(int arr[], int l, int h) +int partition(int arr[], int low, int high) { //Do the comparison and swapping here + int pivot = arr[high]; + int i = low; // iterator for the left partition + int j = high; // iterator for the right partition + while(i < j){ // to ensure that the left & right iterators don't cross each other + while(i<=high && arr[i] < pivot){ // fi nd first element in the left partition which does not satisfy our constraints + i++; + } + while(j>=low && arr[j] >= pivot) { // find first element in the right partition which does not satisfy our constraints + j--; + } + if(i < j){ // to make sure that the swap is still valid (we don't want to accidentally swap elements which are in the correct partition) + swap(&arr[i], &arr[j]); + } + } + swap(&arr[i], &arr[high]); // the index where the left iterator spills into the right partition will be the correct position of the pivot + return i; // return index of pivot } /* A[] --> Array to be sorted, @@ -21,6 +44,19 @@ h --> Ending index */ void quickSortIterative(int arr[], int l, int h) { //Try to think that how you can use stack here to remove recursion. + stack> ranges; + ranges.push({l,h}); + while(!ranges.empty()) { + pair top = ranges.top(); + ranges.pop(); + int low = top.first; // destructuring pair into low and high for better variable names & readability + int high = top.second; + if(low < high) { + int pIndex = partition(arr, low, high); + ranges.push({low, pIndex - 1}); + ranges.push({pIndex + 1, high}); + } + } } // A utility function to print contents of arr @@ -30,12 +66,37 @@ void printArr(int arr[], int n) for (i = 0; i < n; ++i) cout << arr[i] << " "; } + +bool isSorted(int A[], int size) { + for(int i=1; i A[i]) return false; + } + return true; +} // Driver code int main() { + /* TEST CASES */ int arr[] = { 4, 3, 5, 2, 1, 3, 2, 3 }; + // int arr[] = {}; // empty array + // int arr[] = {1}; // array with only one element (already sorted) + // int arr[] = {1, 2, 3, 4}; // already sorted array + // int arr[] = {1, 3, 2, 1, 2, 3, 4, 1, 4, 2, 3}; // array with duplicates + // int arr[] = {5,-1,3,0,-2,4,-3}; // negatives + // int arr[] = {INT_MIN, 0, INT_MIN, INT_MAX}; // extreme values int n = sizeof(arr) / sizeof(*arr); + + if(n == 0) { + cerr<<"Invalid array"< +For recursive binary search, we set the basecase as l>r, which means the element does not exist and we return -1. This case works because Binary +Search operates by halving the search space at every step. By continually halving, at one point we will have covered the entire space. This happens +when we end up with a left pointer which is larger than the right pointer. +We calculate mid by doing l + (r-l)/2 to avoid integer overflow. Then we compare arr[mid] to x. +If arr[mid] > x, that means the element could be present in the left half. So we call the function again, with r = mid - 1, l remains same. +If arr[mid] < x, that means the element could be present in the right half. So we call the function again, with l = mid + 1, r remains same. +If arr[mid] == x, we return mid, which is the position of the search element. +Iterative approach is similar, instead of calling the function recursively, we use a while loop with the appropriate conditions. This approach is +better as it improves the space complexity from O(log n) due to the recursion stack to O(1). +*/ + +/* +Exercise 2 -> +Quicksort is a divide-and-conquer algorithm, which breaks down a problem into smaller subproblems and builds the final solution by solving these smaller +subproblems. The main idea behind quicksort is a "pivot". The pivot is an element which is placed at the correct place in the array. The constraints are: +-> all elements < the pivot are placed to the left of the pivot +-> all elements >= the pivot are placed to the right of the pivot +Basically the array will look like -> [< elements | PIVOT | >= elements] +Once we achieve this structure, we can call the quicksort function again on the left half & the right half (excluding pivot, as it is already in the +sorted position). The constraint for valid recursion will be "atleast 2 elements should be present in the array", since 1 element by itself is +considered to be already sorted. Since we are using low & high pointers, this condition will be -> if(low < high) +The flow of the quickSort() function will be -> +1. Check low/high condition +2. Find partition index for array between low...high +3. Recursively call quickSort() for low to partitionIndex - 1 & for partitionIndex + 1 to high. + +The partition() function is where the core logic resides. We select the pivot as arr[high] always (last element of the array/subarray we are working on). +Then we define 2 pointers i & j which will be the iterators for the left part & right part respectively. The left part should contain elements < pivot +and the right part should contain elements >= pivot. So we run a while loop with the correct conditions to prevent access to invalid indices. Our aim +is to find an element which is >= pivot in the left half as this violates our constraints. Once we find a problematic element in the left half, we break +the loop & i will contain the index to this element. We do the same thing for the right half (j). Once the second while loop breaks, j will contain the +index of an element which is < pivot in the right half. +We then check if(i < j), because it could be possible that we have exceeded the partition and the i & j pointers have spilled into the wrong half. If +i < j, that means both pointers are still in the correct half and we swap the problematic elements so that they can be in the correct halves. This whole +process continues till i < j. Once we break out of the main loop, we have successfully partitioned the elements into left and right halves according to +our constraints. The only thing remaining now is to put the pivot in the correct position and then returning the correct index of pivot (partition index). +The correct index for the pivot will be the index where i spills over into the right half invalidating the i < j condition. +The final condition of the array after going through the partition function will be: +-> arr[low...i-1] = elements < pivot +-> arr[i] = pivot in correct position +-> arr[i+1...high] = elements >= pivot + +Once the partition index is returned, the main quickSort() function continues recursively and we end up with a sorted array. TC/SC explained in the code. + +The worst case TC of O(n^2) can be avoided with good pivot selection. A deterministic algorithm called 'median of medians' exists, which splits the array into +groups of 5, sorts each group of 5 elements (O(1), since 5 is a constant). Then it finds the median of each group and puts these medians into an array of medians. +Then the same process of splitting into groups and findind the median is done again, till we end up with one median. This final median is used as a pivot for the +original array. Repeat this process during pivot selection for each subarray. +This is proven to avoid the O(n^2) worst case. In most cases, the overhead of finding the median of medians every time is not worth it. The randomized pivot +selection process works well and picking the worst possible pivot has a very low probability. +In our logic (selecting last element as pivot), the worst case TC will be for a reverse sorted array & already sorted array. The already sorted array case is +handled in code. +*/ + +/* +Exercise 3 -> +Brute force approach here would be to traverse the entire linked list and using an int to track the lenght of the list. Then we iterate again +length/2 times and we reach the middle node. This requires 2 traversals but the time complexity would still be O(n), where n = length of linked list. +Optimized approach (only one traversal required): +Used the fast and slow pointer technique to print the middle of the linked list. +Both pointers start at head. Fast jumps 2 nodes (fast = fast->next->next) & slow jumps one node at a time. +We need to be careful about null pointer exceptions here. If size of the linkedlist is even, fast ptr will end up overshooting the last node and +point to null. If size is odd, then fast pointer will point to the last node at the end. So the while loop condition will be: +while(fast!=nullptr && fast->next!= nullptr) +The slow pointer will point to the middle of the linkedlist after the while loop completed execution. +This results in n/2 iterations, but that still ends up being O(n) time complexity asymptotically. +*/ + +/* +Exercise 4 -> +The merge sort algorithm is also a divide-and-conquer algorithm like quick sort. The idea here is to continually split the array into halves. We keep +splitting till we reach a subarray with only one element (already sorted). Then we merge the smaller sorted subarrays back together. This merge step +uses a temp array to compare elements from each half and place them in sorted order. After merging the parent array is updated with this sorted result. +We use a mid element to split the array into half everytime. We recursively call mergeSort on (low, mid) and (mid+1, r). The if condition in the +mergeSort() function if(l < r) makes sure that there are atleast 2 elements in the array. Mid calculation is done as l + (r-l)/2 to avoid integer overflow. +In the merge function, we use 2 pointers i and j to act as iterators for the left & right subarrays respectively. We then merge the 2 subarrays into a +temp array in sorted order. After that we handle the case where either the left or the right subarray has elements which havent been merged. After we construct +the merged array, we simply replace the elements between indices l & r in the original array, with the elements of this merged array. +TC, SC explained in the code file. +*/ + +/* +Exercise 5 -> +In the iterative approach, everything remains the same logically. The only change is replacing the recursive quickSort() function with a stack approach. +If we observe the previous recursive approach, we tweak the low/high pointers based on the partition index and call the function again. We do the same +thing here, except we push these new low/high pointers onto a stack and pop them in a LIFO manner inside a loop to mimic recursion. +The advantage of this approach is: +- There is no recursion limit/stack overflow errors. +- It limits the recursive call overhead. +*/ \ No newline at end of file