Distribute randomly to a matrix - excel

I have a matrix, let's say 5 columns and 10 rows.
Then I have 30 stars. I want to put them into the matrix so that the number of stars on each row is the same and the number of stars on each column is the same (3 stars each row and 6 stars each column).
If I have 40 stars, there should be 4 stars each row and 8 stars each column.
I can do the matrix by hand and I really did both cases. But the bigger the matrix is, the harder I fill the stars.
I suppose there should be a principle behind it but still haven't figure it out.
I am using VBA in Excel to generate the 5x10 matrix with 30 stars, but it takes some minutes to try all possibilities with a loop.
Sub test()
Dim xRange As Range
Set xRange = Selection
GoTo FillX
GoTo CheckRows
FillX:
xRange.Clear
xRange.HorizontalAlignment = xlCenter
For i1 = 1 To 5
For i2 = 1 To 6
v = Int(Rnd() * 10 + 1)
While xRange.Cells(v, i1).Value = "x"
v = Int(Rnd() * 10 + 1)
Wend
xRange.Cells(v, i1).Value = "x"
Next
Next
CheckRows:
x = 0
For Each Row In xRange.Rows
If WorksheetFunction.CountA(Row) <> 3 Then
x = x + 1
End If
Next
While x <> 0
GoTo FillX
GoTo CheckRows
Wend
End Sub
Is there a solution which can randomly distribute stars to a range of any size?

Goto should be avoided. Use loops instead.
This code first calculates the number of stars for each row, then uses nested loops to enter them, so there are never more stars in a row. By stepping to the next row, but retaining the column position, and then starting in the same row in the first column, you can ensure that there are no more than the defined number of stars in a column, either. The blank cells will travel diagonally from top left to right. You can see this when you apply conditional formatting.
With this pattern, you don't need to use trial and error and the code runs super fast.
Sub test()
Dim gridRows As Long, gridColumns As Long, Stars As Long
Dim myRow As Long, myColumn As Long
Dim i As Long, j As Long
Dim rowCounter As Long, columnCounter As Long, rowOffset As Long
Dim ws As Worksheet
Set ws = Me
'You can get the grid rows and columns and the number of stars from
' user input or from worksheet cells in you want. Just make sure
' they end up in the variables below.
gridRows = 20
gridColumns = 10
Stars = 60
mycolumns = Stars / gridRows
myRows = Stars / gridColumns
j = 1
rowCounter = 1
columnCounter = 1
ws.Range("A1:zz9990").ClearContents
For j = 1 To gridRows
rowOffset = 0
For i = 1 To gridColumns
ws.Cells(j, i) = 1
columnCounter = columnCounter + 1
If columnCounter > mycolumns Then
j = j + 1
columnCounter = 1
rowOffset = 1
End If
Next i
j = j - rowOffset
Next j
' randomize the results
Dim SortRange As Range
' randomize the columns
Set SortRange = ws.Range(ws.Cells(gridRows + 1, 1), ws.Cells(gridRows + 1, gridColumns))
' enter random numbers
For Each cel In SortRange
Debug.Print cel.Address
cel.Value = Rnd
Next cel
' sort left to right
ws.Sort.SortFields.Clear
ws.Sort.SortFields.Add2 Key:=SortRange _
, SortOn:=xlSortOnValues, Order:=xlAscending, DataOption:=xlSortNormal
With ws.Sort
.SetRange Range("A1", Cells(gridRows + 1, gridColumns))
.Header = xlGuess
.Orientation = xlLeftToRight
.SortMethod = xlPinYin
.Apply
End With
' clear the random numbers
SortRange.ClearContents
Set SortRange = ws.Range(ws.Cells(1, gridColumns + 1), ws.Cells(gridRows, gridColumns + 1))
' randomize the rows
' enter random numbers
For Each cel In SortRange
Debug.Print cel.Address
cel.Value = Rnd
Next cel
'Sort Rows
ws.Sort.SortFields.Clear
ws.Sort.SortFields.Add2 Key:=SortRange _
, SortOn:=xlSortOnValues, Order:=xlAscending, DataOption:=xlSortNormal
With ws.Sort
.SetRange Range("A1", Cells(gridRows, gridColumns + 1))
.Header = xlGuess
.Orientation = xlTopToBottom
.SortMethod = xlPinYin
.Apply
End With
' clear the random numbers
SortRange.ClearContents
End Sub
Edit after comment: Added code to randomize the results. Add a row with random numbers to the end of the table, sort by those random numbers left to right, and remove them again. Do the same with a column of random numbers and sort top to bottom, then remove the helper numbers.
The screenshot shows a grid with 20 rows, 10 columns and 60 stars using conditional formatting to show the distribution better.
A grid with 60 rows, 30 columns and 1200 stars takes less than a second to build (without using conditional formatting).

This code works a lot quicker (under 1 second mostly).
Sub Button1_Click()
Set xRange = [a1:e10]
x = 0
While x < 6
xRange.Clear
xRange.HorizontalAlignment = xlCenter
For i1 = 1 To 4
For i2 = 1 To 6
Do
g3 = False
Do
v = Int(Rnd * 10 + 1)
Loop While xRange.Cells(v, i1).Value = "x"
xRange.Cells(v, i1).Value = "x"
If WorksheetFunction.CountA(xRange.Rows(v)) > 3 Then
xRange.Cells(v, i1).Value = " "
g3 = True
End If
Loop While g3 = True
xRange.Cells(v, i1).Value = "x"
Next i2
Next i1
x = 0
For Each Row In xRange.Rows
If WorksheetFunction.CountA(Row) = 2 Then x = x + 1
Next Row
Wend
For i2 = 1 To 6
Do
g3 = False
Do
v = Int(Rnd * 10 + 1)
Loop While xRange.Cells(v, 5).Value = "x"
xRange.Cells(v, 5).Value = "x"
If WorksheetFunction.CountA(xRange.Rows(v)) > 3 Then
xRange.Cells(v, i1).Value = " "
g3 = True
End If
Loop While g3 = True
xRange.Cells(v, 5).Value = "x"
Next i2
End Sub
It performs a condition on the rows as well, checking that they have no more than 3 stars in them.
This is done for the first four rows, and then it checks to see if there are at least 6 rows in the last column that can take another star (i.e. have exactly 2 stars already).

Related

Prevent a macro from freezing / crashing / turning white the Excel window?

At work I am working on a macro/UserForm in Excel for someone. It's worked great on spreadsheets with 15k rows, but takes about 10 minutes to run, while Excel appears to be frozen (not responding). We've tried to run it on a 250k row sheet for about 8 hours and it never completed.
Is there a way to make it more effecient, or at least allow the user to see view its progress without Excel being locked up?
About the Macro
Users are asigned tasks, and arn't supposed to be assigned the same one within 365 days. There are 47 Columens and 250k Rows of users. Rows are sorted by username, create date, task. The macro goes row by row to first make sure its the same user, and then find instances of a task being asigned within the 365 day window, and flagging the row red. Then it checks the next row against the initial to make sure its also not within 365 days.
After reading a few dozens other posts, I'm not sure if this is the most effecient way of doing it. Iif you see anyway to make my code more efficient that would be greatly appreciated!
Sub highlight_newer_dates_v2()
Dim i As Long, j As Long
Dim lastRow As Long
Dim AccountNo As String, SpecialtyTo As String, CreateDate1 As Date, CreateDate2 As Date
Dim lastNonRedRow As Long
lastRow = Cells(Rows.Count, "I").End(xlUp).Row
lastNonRedRow = 0
For i = 2 To lastRow
AccountNo = Cells(i, 9).Value
SpecialtyTo = Cells(i, 13).Value
CreateDate1 = Cells(i, 5).Value
If Cells(i, 9).Interior.Color = RGB(255, 0, 0) Then
If lastNonRedRow = 0 Then
For j = i - 1 To 2 Step -1
If Cells(j, 9).Interior.Color <> RGB(255, 0, 0) Then
lastNonRedRow = j
Exit For
End If
Next j
End If
If lastNonRedRow <> 0 Then
CreateDate1 = Cells(lastNonRedRow, 5).Value
End If
Else
lastNonRedRow = i
End If
For j = i + 1 To lastRow
If Cells(j, 9).Value = AccountNo And Cells(j, 13).Value = SpecialtyTo Then
CreateDate2 = Cells(j, 5).Value
If Abs(CreateDate2 - CreateDate1) <= 365 Then
If CreateDate2 > CreateDate1 Then
Rows(j).Interior.Color = RGB(255, 0, 0)
Else
Rows(i).Interior.Color = RGB(255, 0, 0)
End If
End If
End If
Next j
Next i
End Sub
I've tried doing a loop to make it more effecient but couldn't get it to work properly.
Looping through row by row in vba is going to be inefficient. If I am understanding your problem correctly, you should 1. sort by date 2. sort by task 3 sort by user 4. use the formulas below. You would have to add even more columns if you might have more than 3 sequences, but its a pattern. You could also have vba sort by these values, put in the formulas, past the any true column as values and then sort back to your original order.
SEQUNCE
=COUNTIFS($A$5:A5,A5,$B$5:B5,B5)
2nd too close to 1st
=IF(AND(D6=2,(C6-C5)<365),1,0)
3rd too close to 2nd
=IF(AND(D7=3,(C7-C6)<365,E6=0),1,0)
ANY TRUE
=MAX(E5:F5)
Process 15,000 rows in a few seconds, 250,000 in < 1 minute.
Option Explicit
Sub highlight_newer_dates_v4()
Const COLS = 47
Dim ws As Worksheet
Dim dictTask, k, colDates As Collection
Dim bRed() As Boolean
Dim lastrow As Long, i As Long, j As Long
Dim n As Long, r As Long, r1 As Long, r2 As Long
Dim dt As Date, dt1 As Date, dt2 As Date
Dim task As String, accno As String
Dim t0 As Single
' store task and dates for one account
Set dictTask = CreateObject("Scripting.Dictionary")
' specify your sheet here
Set ws = ThisWorkbook.Sheets("Testdata")
'Call testData(ws, 250000)
' process data sheet
t0 = Timer
Application.ScreenUpdating = False
With ws
lastrow = .Cells(.Rows.Count, "I").End(xlUp).Row
ReDim bRed(1 To lastrow)
For r = 2 To lastrow
accno = Trim(.Cells(r, 9)) 'I
task = Trim(.Cells(r, 13)) 'M
dt = CDate(.Cells(r, 5)) 'E
' store tasks in dictionary
If Not dictTask.exists(task) Then
dictTask.Add task, New Collection
n = n + 1
End If
dictTask(task).Add Array(r, dt)
' is ths last task for person then check dates
If (.Cells(r + 1, 9)) <> accno Then
For Each k In dictTask
task = CStr(k)
Set colDates = dictTask(k)
' check interval for all permutations
' is > 365 days
For i = 1 To colDates.Count - 1
r1 = colDates(i)(0)
dt1 = colDates(i)(1)
dt = DateAdd("d", 365, dt1) '365 days later
If bRed(r1) = False Then
For j = i + 1 To colDates.Count
' color rows red if <= 365
r2 = colDates(j)(0)
dt2 = colDates(j)(1)
If bRed(r2) = False And dt2 <= dt Then
bRed(r2) = True
'.Cells(r2, 1).Resize(, COLS).Interior.Color = vbRed
.Cells(r2, 1).Interior.Color = vbYellow
End If
Next
End If
Next
Next
' clear data for next account
dictTask.RemoveAll
n = 0
End If
Next
End With
Application.ScreenUpdating = True
' result
MsgBox Format(lastrow, "0,000") & " rows scanned in " _
& Format(Timer - t0, "0.0 secs")
End Sub
Sub testData(maxrow As Long, ws As Worksheet)
Const USERS = 99
Dim TaskCount As Long
TaskCount = Int(maxrow / USERS)
Dim n As Long, t0 As Single: t0 = Timer
Application.ScreenUpdating = False
With ws
.Cells.Clear
.Columns("E:E").NumberFormat = "dd-mmm-yyyy"
.Range("E1") = "Date"
.Range("I1") = "AccNo"
.Range("M1") = "Task"
For n = 2 To maxrow
.Cells(n, 5) = DateAdd("d", 1000 * Rnd(), DateSerial(2020, 1, 1))
.Cells(n, 9) = "ID_" & Format(Rnd() * USERS + 1, "000")
.Cells(n, 13) = "Task_" & Format(Rnd() * TaskCount + 1, "0000")
Next
.Columns.AutoFit
' sort username, create date, task
With .Sort
With .SortFields
.Clear
.Add2 Key:=Range("I1:I" & maxrow), SortOn:=xlSortOnValues, _
Order:=xlAscending, DataOption:=xlSortNormal
.Add2 Key:=Range("E1:E" & maxrow), SortOn:=xlSortOnValues, _
Order:=xlAscending, DataOption:=xlSortNormal
.Add2 Key:=Range("M1:M" & maxrow), SortOn:=xlSortOnValues, _
Order:=xlAscending, DataOption:=xlSortNormal
End With
.SetRange Range("E1:M" & maxrow)
.Header = xlGuess
.MatchCase = False
.Orientation = xlTopToBottom
.SortMethod = xlPinYin
.Apply
End With
End With
Application.ScreenUpdating = True
MsgBox Format(maxrow, "0,000") & " rows created in " & Format(Timer - t0, "0.0 secs")
End Sub

Converting weekly data in a table to monthly data using VBA

I have a table of hours against weeks (start of the week is a Sunday). The weekly data goes up to 12-16 months dependent on user input. I want to create a VBA macro which will iterate through this table of weekly hours data and convert the columns into monthly data.
Example:
All October 2021 related columns will collapse into 1 column called Oct-21. This will also combine the hours. 2nd row in the image below would equal 4+3+4+0= therefore value would be 11 in the new combined column's 2nd row.
My current thinking was calculating the Sundays between the start date and the last date which is below:
Dim d As Date, format As String, w As Long, FirstSunday As String
format = format(lastMonth, "Medium Date")
d = DateSerial(Year(format), Month(format), 1)
w = Weekday(d, vbSunday)
FirstSunday = d + IIf(w <> 1, 8 - w, 0)
Any ideas on how to do this?
Not sure how you want to group the weeks into months as some months will have 5 weeks. This code inserts a column when the month changes and then fills it with a sum formula for the relevant week columns. It assumes the dates are on row 1 , the task numbers in column 1 and the first week is in column 2.
Option Explicit
Sub ByMonth()
Dim wb As Workbook, ws As Worksheet
Dim LastCol As Long, LastRow As Long, c As Long, n As Long
Dim dt As Date
Set wb = ThisWorkbook
Set ws = wb.Sheets("Sheet1")
LastCol = ws.Cells(1, Columns.Count).End(xlToLeft).Column
LastRow = ws.Cells(Rows.Count, 1).End(xlUp).Row
' scan cols from right to left insert new columns
Application.ScreenUpdating = False
For c = LastCol + 1 To 3 Step -1
' add columns on month change
If Month(ws.Cells(1, c)) <> Month(ws.Cells(1, c - 1)) Then
ws.Columns(c).Insert
With ws.Columns(c)
.HorizontalAlignment = xlCenter
'.Interior.Color = RGB(255, 255, 200)
.Font.Bold = True
.Cells(1).NumberFormat = "#"
End With
End If
Next
' scan left to right filling new cols with sum() formula
' hide weekly columns
LastCol = ws.Cells(1, Columns.Count).End(xlToLeft).Column
n = 0
For c = 2 To LastCol + 1
If ws.Cells(1, c) = "" Then
dt = ws.Cells(1, c - 1)
ws.Cells(1, c) = MonthName(Month(dt), True) & " " & Year(dt)
ws.Cells(2, c).Resize(LastRow - 1).FormulaR1C1 = "=SUM(RC[-" & n & "]:RC[-1])"
n = 0
Else
ws.Columns(c).EntireColumn.Hidden = True
n = n + 1
End If
Next
' copy visible month columns to sheet2
ws.Cells.SpecialCells(xlCellTypeVisible).Copy
With wb.Sheets("Sheet2")
.Activate
.Range("A1").PasteSpecial xlPasteValues
.Range("A1").Select
End With
' end
ws.Columns.Hidden = False
Application.ScreenUpdating = True
Application.CutCopyMode = False
MsgBox "Done"
End Sub
Please, try the next code. It assumes that in column A:A, starting from the 6th row, there are (not sorted) tasks. If they are sorted, the code will run without problem, too. It uses arrays and a dictionary and mostly working in memory, should be very fast for big ranges:
Sub SumWeeksMonths()
Dim sh As Worksheet, sh1 As Worksheet, lastR As Long, arrWk, arrMonths, arrTasks
Dim i As Long, k As Long, j As Long, El, arr, arrFin, dict As New Scripting.Dictionary
Set sh = ActiveSheet 'use there the sheet to be processed
Set sh1 = sh.Next 'use here the sheet where the processed result to be returned
lastR = sh.Range("A" & sh.rows.count).End(xlUp).row 'last row (in column A:A)
arrWk = sh.Range(sh.Range("B5"), sh.cells(5, sh.Columns.count).End(xlToLeft)).Value 'place the Week headers in a 2D array
ReDim arrMonths(UBound(arrWk, 2) - 1)'redim the 1D array to keep the unique munths, at a maximum size
For i = 1 To UBound(arrWk, 2) - 1 'create the array of (only) months:
If month(DateValue(arrWk(1, i))) <> month(DateValue(arrWk(1, i + 1))) Then
k = k + 1: arrMonths(k) = Format(DateValue(arrWk(1, i + 1)), "mmm-yyyy")
Else
arrMonths(k) = Format(DateValue(arrWk(1, i)), "mmm-yyyy")
End If
Next i
ReDim Preserve arrMonths(k) 'preserve only the existing Date elements
For Each El In sh.Range("A4:A" & lastR).Value
dict(El) = 1 'extract the unique tasks (only to count them for ReDim the necessary array)
Next El
'place all the range to be processed in an array (for faster iteration):
arr = sh.Range("A5", sh.cells(lastR, sh.cells(5, sh.Columns.count).End(xlToLeft).Column)).Value
ReDim arrFin(1 To UBound(dict.Keys) + 1, 1 To UBound(arrMonths) + 2) 'reDim the final array to keep processed data
ReDim arrTasks(UBound(arrMonths)) 'redim the array to temporarily keep the array of each task summ
dict.RemoveAll: k = 0 'clear the dictionary and reitinialize the K variable
For i = 2 To UBound(arr) 'iterate between the main array elements:
If Not dict.Exists(arr(i, 1)) Then 'if the Task key does not exist:
For Each El In arrMonths 'iterate between each month in arrMonths:
For j = 2 To UBound(arr, 2) 'iterate between all arr columns for the i row:
If month(DateValue(arr(1, j))) = month(El) Then 'if column months is a specific arrMonths column:
arrTasks(k) = arrTasks(k) + arr(i, j) 'sumarize everything in the arrTask each element
End If
Next j
k = k + 1 'increment k, for the next month
Next El
dict.Add arr(i, 1), arrTasks 'create the dictionary key with the tasks array as item
ReDim arrTasks(UBound(arrMonths)): k = 0 'reinitialize arrTasks and k variable
Else 'if dictionary (task) key exists:
For Each El In arrMonths
For j = 2 To UBound(arr, 2)
If month(DateValue(arr(1, j))) = month(El) Then
arrTasks(k) = dict(arr(i, 1))(k) + arr(i, j) 'add the sum to the allready existing elements
End If
Next j
k = k + 1
Next El
dict(arr(i, 1)) = arrTasks 'make the item the updaded array
ReDim arrTasks(UBound(arrMonths)): k = 0 'reinitialize arrTasks and k variable
End If
Next i
'place the processed values in final array (arrFin):
For i = 0 To UBound(arrMonths) 'firstly the headers:
arrFin(1, i + 2) = arrMonths(i)
Next i
'Extract the tasks value for each month and place in the final array appropriate columns:
For i = 0 To dict.count - 1 'iterate between the dictionary elements:
arrFin(i + 2, 1) = dict.Keys(i) 'place the task in the array first column, starting from the second row
For j = 0 To UBound(dict.items(i)) 'iterate between the dictionary item array elements
arrFin(i + 2, j + 2) = dict.items(i)(j) 'place the appropriate array elements in the final array (arrFin)
Next j
Next i
'drop the final array at once and make some formatting:
With sh1.Range("A1").Resize(UBound(arrFin), UBound(arrFin, 2))
.Value = arrFin
With .rows(1)
.Font.Bold = True
.Interior.ColorIndex = 20
.BorderAround 1
End With
.EntireColumn.AutoFit
.BorderAround 1
End With
sh1.Activate 'to see the processing result...
MsgBox "Ready..."
End Sub
Please, test it and send some feedback.

Exclude counter number in selecting random names in Excel VBA

I have working code I got on the internet where a name is randomly picked from Column A with a default counter of "0" (Column B). I added a modification where if the name has been picked, the value of "0" becomes "1". However I am confused as to where I can add the logic where if the value in Column B is already 1, it will not be included in the next random pick since technically, the person with the value of 1 in the counter has already won.
Sample data:
Names | Counter
Newt | 0
Thomas | 0
Teresa | 1
Katniss | 0
Peeta | 0
Gale | 0
Haymitch | 0
Hazel Grace | 0
Augustus | 0
Code when "Draw Winner" is clicked:
Sub draw_winners()
draw
End Sub
Function draw()
Dim x As Integer
Dim delay_ms As Integer
Dim prize_y As Integer
Dim name_matched As Boolean
Dim randm As Integer
x = get_max
'CELL (ROW, COLUMN)
delay_ms = 20 'how many draws before final
draw_winner:
randm = rand_num(x)
Cells(1, 3).Value = Cells(randm, 1).Value
'winner_window.winner_name.Caption = Cells(1, 3).Value
name_matched = check_names(Cells(1, 3).Value, 1)
If delay_ms > 0 Then
WaitFor (0.1)
delay_ms = delay_ms - 1
GoTo draw_winner
End If
If name_matched = True Then
GoTo draw_winner
End If
Cells(randm, 2).Value = 1
End Function
Function check_names(name As String, rndm As Integer) As Boolean
Dim i As Integer
Dim winner As String
check_names = False
i = 2
check_name:
winner = Cells(i, 4).Value
If winner <> "" Then
If winner = name And i <> rndm Then
check_names = True
End If
End If
i = i + 1
If i < 1000 Then
GoTo check_name
End If
End Function
Function get_max() As Integer
Dim i As Integer
i = 2
check_blank_cell:
If Cells(i, 1).Value <> "" Then 'starts at the second row
i = i + 1
If i > 10000 Then
MsgBox "Max Limit Reached!"
Else
GoTo check_blank_cell
End If
End If
get_max = i - 1
End Function
Function rand_num(max As Integer) As Integer
Dim Low As Double
Dim High As Double
Low = 2 '<<< CHANGE AS DESIRED
High = max '20 '<<< CHANGE AS DESIRED
r = Int((High - Low + 1) * Rnd() + Low)
rand_num = r
End Function
Sub WaitFor(NumOfSeconds As Single)
Dim SngSec As Single
SngSec = Timer + NumOfSeconds
Do While Timer < SngSec
DoEvents
Loop
End Sub
Apologies if this has been asked. Your help will be deeply appreciated.
An easy (and fast) way would be to sort data by counter as a first step (so all 0 counters come first) before drawing a new name.
With ActiveSheet.Sort
.SortFields.Clear
.SortFields.Add2 Key:=Range("B:B"), SortOn:=xlSortOnValues, Order:=xlAscending, DataOption:=xlSortNormal
.SetRange Range("A:B")
.Header = xlYes
.MatchCase = False
.Orientation = xlTopToBottom
.SortMethod = xlPinYin
.Apply
End With
and use the amount of zeros x = Application.WorksheetFunction.CountIf(Range("B:B"), 0) as maximum for your random number generator rand_num(x). This way only names with 0 are drawn.
Image 1: Only the selected rows are used to draw a name.
Also see How to Sort Data in Excel using VBA (A Step-by-Step Guide).
The below will return an array of names that have not yet won. A random name gets picked and column B gets adjusted accordingly. Maybe it comes in handy:
Sub Test()
Dim lr As Long
Dim arr As Variant
Dim nom As String
Dim rng As Range
With Sheet1 'Change accordingly
'Get last used row
lr = .Cells(.Rows.Count, 1).End(xlUp).Row
'Get range into memory (array)
arr = Filter(.Evaluate("TRANSPOSE(If(B2:B" & lr & "=0,A2:A" & lr & ",""|""))"), "|", False)
If UBound(arr) = -1 Then Exit Sub
'Get a random name from array
nom = arr(Int(Rnd() * (UBound(arr) + 1)))
'Get the range where name resides
Set rng = .Range("A2:A" & lr).Find(nom, LookIn:=xlValues, lookat:=xlWhole)
'Change value in B column
rng.Offset(, 1).Value = 1
'Do something with randomly picked name
Debug.Print nom
End With
End Sub

How can I make For loops with an If statement more efficient when iterating over rows in VBa?

I have a For loop nested inside another For loop which iterates over every row in a spreadsheet.
The nested for loop below checks the current row and then loops over every row in the spreadsheet to see if it matches the If statement criteria. If so, it changes a bool to True and exits the nested loop.
This method takes far too long. The spreadsheet is 1000 rows x 27 columns and will take forever to run through on the small PC I'm using.
The Code:
Sub Check_Errors()
Dim x As Integer
Dim lastRow As Long
Dim duplicateData As Boolean
Set Data = ThisWorkbook.Sheets("Data") 'Worksheet with Raw data
Set Errors = ThisWorkbook.Sheets("Errors") 'Where any flagged rows are copied to.
x = 2
lastRow = Data.Cells(Data.Rows.Count, "A").End(xlUp).Row
duplicateData = False
'Copies the headings from Data worksheet to Error worksheet
For j = 1 To 26
Errors.Cells(1, j).Value = Data.Cells(1, j).Value
Next j
Errors.Cells(1, 27).Value = "Error Type"
For i = 2 To lastRow
wrongSpeciality = False
For j = 2 To 300
If ((Data.Cells(i, 19) < Data.Cells(j, 19) + (Data.Cells(j, 20) / 1440) + (Data.Cells(j, 21) / 1440) _
And Data.Cells(i, 19) >= Data.Cells(j, 19)) _
Or _
(Data.Cells(i, 19) + (Data.Cells(i, 20) / 1440) + (Data.Cells(i, 21) / 1440) <= Data.Cells(j, 19) + (Data.Cells(j, 20) / 1440) + (Data.Cells(j, 21) / 1440) _
And Data.Cells(i, 19) + (Data.Cells(i, 20) / 1440) + (Data.Cells(i, 21) / 1440) > Data.Cells(j, 19))) _
_
And Data.Cells(i, 18) = Data.Cells(j, 18) _
And Data.Cells(i, 22) = Data.Cells(j, 22) _
And Not i = j Then
duplicateData = True
Exit For
End If
Next j
'If true then copy flagged row to Error worksheet and add additional column with reason row was flagged.
If duplicateData Then
For j = 1 To 26
Errors.Cells(x, j).Value = Data.Cells(i, j).Value
Next j
Errors.Cells(x, 27).Value = "Time overlapping"
x = x + 1
End If
Next i
The Data
ClinicalTime and AdminTime are in minutes and need to be divided by 1440 before being added to Time to get the correct finish time.
James sees someone at 13:00 on the 12th Jan and finishes at 13:30. But it also shows he saw someone at 13:25 which isn't possible as he was with someone during that time.
The above code will change duplicateData to True for both these rows, but will take a very long time to do so over thousands of these instances.
Columns 18 19 20 21 22
Date Time ClinicalTime AdminTime Clinician
12/01/18 13:00 20 10 James
12/01/18 13:25 10 20 James
12/01/18 14:30 40 0 James
14/01/18 10:00 20 20 Samantha
Worth noting is a finish time can be the same as a start time, so James could see a patient at 11:00, finish at 11:30, and have start time for the next patient at 11:30 and there would be no need to flag these two.
Imagine the following data:
Sort it by BOTH:
Column V (Clinician) A … Z
AND Column W (START) low … high
I added 3 columns W, X and Y (you can use others). Formulas are:
Column W: =R:R+S:S (adds together start date and time)
Column X: =R:R+S:S+T:T/1440+U:U/1440 (calculates end date/time)
Y2 (and copy down): =IF(AND(V2=V1,W2<X1),"Overlapping","-")
Then filter by Column Y (Overlapping) and you have your data.
How does the formula work?
The formula checks for each row if the Clinician is the same as in the row before (otherwise it is the first row of that Clinician which can never be a overlap). Then it checks if the START is before the END of the row before. If so it is overlapping, otherwise not.
Note that this method works only on properly sorted data.
If using formulas does not meet your needs then the idea of this method could also be used in VBA. This should be much faster because only minimal amount of data would be needed to test and you need to loop through all rows only once.
Option Explicit
Public Sub CheckForOverlappings()
Dim wsData As Worksheet
Set wsData = ThisWorkbook.Worksheets("Data")
Dim wsErrors As Worksheet
Set wsErrors = ThisWorkbook.Worksheets("Errors")
Dim LastDataRow As Long
LastDataRow = wsData.Cells(wsData.Rows.Count, "A").End(xlUp).Row
Dim LastErrorRow As Long
LastErrorRow = 2
'sort data by …
With wsData.Sort
.SortFields.Clear
'… field Clinician
.SortFields.Add2 Key:=Range("V2"), SortOn:=xlSortOnValues, Order:=xlAscending, DataOption:=xlSortNormal
'… field Date
.SortFields.Add2 Key:=Range("R2"), SortOn:=xlSortOnValues, Order:=xlAscending, DataOption:=xlSortNormal
'… field Time
.SortFields.Add2 Key:=Range("S2"), SortOn:=xlSortOnValues, Order:=xlAscending, DataOption:=xlSortNormal
.SetRange Range("1:" & LastDataRow)
.Header = xlYes
.MatchCase = False
.Orientation = xlTopToBottom
.SortMethod = xlPinYin
.Apply
End With
'write headers for error sheet
wsErrors.Cells(1, 1).Resize(ColumnSize:=26).Value = wsData.Cells(1, 1).Resize(ColumnSize:=26).Value
wsErrors.Cells(1, 27).Value = "Error Type"
'read relevant data into array
Dim arrData As Variant
arrData = wsData.Range(wsData.Cells(1, 18), wsData.Cells(LastDataRow, 22))
'initialize start/enddate with first data row (= row 2)
Dim StartDate As Date
StartDate = arrData(2, 1) + arrData(2, 2)
Dim EndDate As Date
EndDate = StartDate + arrData(2, 3) / 1440 + arrData(2, 4) / 1440
Dim iRow As Long
For iRow = 3 To UBound(arrData, 1) 'loop from data row 2 (= row 3) we used data row 1 in initialization already
'determine start date of current row
StartDate = arrData(iRow, 1) + arrData(iRow, 2)
If arrData(iRow, 1) = arrData(iRow - 1, 1) And StartDate < EndDate Then 'check same cinician and overlapping
'copy column 1 … 26 to error sheet
wsErrors.Cells(LastErrorRow, 1).Resize(ColumnSize:=26).Value = wsData.Cells(iRow, 1).Resize(ColumnSize:=26).Value
LastErrorRow = LastErrorRow + 1
End If
'remember end date of current row (for comparison with next row)
EndDate = StartDate + arrData(iRow, 3) / 1440 + arrData(iRow, 4) / 1440
Next iRow
End Sub

Randomize a set of values without repeating value index

I have a requirement to randomize or shuffle a cet of cells in column A subject to the constraint that no cell remains unchanged.
I am placing the candidate randomization in column C with this code:
Sub ShuffleCutandDeal()
Dim A As Range, C As Range
Dim B As Range, cell As Range
Set A = Range("A1:A24")
Set B = Range("B1:B24")
Set C = Range("C1")
A.Copy C
Randomize
For Each cell In B
cell.Value = Rnd()
Next cell
With ActiveSheet.Sort
.SortFields.Clear
.SortFields.Add Key:=Range("B1:B24") _
, SortOn:=xlSortOnValues, Order:=xlAscending, DataOption:=xlSortNormal
.SetRange Range("B1:C24")
.Header = xlNo
.MatchCase = False
.Orientation = xlTopToBottom
.SortMethod = xlPinYin
.Apply
End With
End Sub
The randomization works, but sometimes I get something like:
When I see that a data item has not moved, I re-run the code until all items have been moved.
It seems to me that this "If at first you don't succeed........." approach is really dumb.
Is there a better way to randomize and insure that all the items have moved in one pass ???
EDIT#1:
Based on iliketocode's comment, I attempted to adapt Tony's approach in this post to VBA:
Sub Tony()
Dim A As Range, C As Range
Dim m As Long, t As Variant, i As Long
Dim wf As WorksheetFunction
Set wf = Application.WorksheetFunction
Set A = Range("A1:A24")
Set C = Range("C1:C24")
A.Copy C
For m = 1 To 22
i = wf.RandBetween(m + 1, 24)
t = C(i)
C(i) = C(m)
C(m) = t
Next m
t = C(23)
C(23) = C(24)
C(24) = t
End Sub
I guess the idea is to:Swap C1 with a random pick between C2 and C24 then
Swap C2 with a random pick between C3 and C24 thenSwap C3 with a random pick between C4 and C24 then................Swap C22 with a random pick between C23 and C24 and finallySwap C23 and C24.
I ran this 1000 times with no unwanted matches appearing.
I had to write my own version of the worksheet's native RANK function in order to compare to the ordinal placement of the randomized value but I think this may be getting close.
Option Explicit
Sub shuffleCutDeal()
Dim i As Long, j As Long, tmp As Variant, vVALs As Variant
With Worksheets("Sheet1")
.Columns("B:D").ClearContents
'get the values from the worksheet
vVALs = .Range(.Cells(1, 1), .Cells(Rows.Count, 1).End(xlUp)).Value2
'add an extra 'column' for random index position ('helper' rank)
ReDim Preserve vVALs(LBound(vVALs, 1) To UBound(vVALs, 1), _
LBound(vVALs, 2) To UBound(vVALs, 2) + 1)
'populate the random index positions
Randomize
For i = LBound(vVALs, 1) To UBound(vVALs, 1)
vVALs(i, 2) = Rnd
Next i
'check for duplicate index postions and re-randomize
Do
Randomize
For i = LBound(vVALs, 1) To UBound(vVALs, 1)
If arrRank(vVALs(i, 2), Application.Index(vVALs, 0, 2)) = i Then
vVALs(i, 2) = Rnd
Exit For
End If
Next i
Loop Until i > UBound(vVALs, 1)
'sort the variant array
For i = LBound(vVALs, 1) + 1 To UBound(vVALs, 1)
For j = LBound(vVALs, 1) To UBound(vVALs, 1) - 1
If vVALs(i, 2) > vVALs(j, 2) Then
tmp = Array(vVALs(i, 1), vVALs(i, 2))
vVALs(i, 1) = vVALs(j, 1)
vVALs(i, 2) = vVALs(j, 2)
vVALs(j, 1) = tmp(0)
vVALs(j, 2) = tmp(1)
End If
Next j
Next i
'[optional] get rid of the 'helper' rank
'ReDim Preserve vVALs(LBound(vVALs, 1) To UBound(vVALs, 1), _
LBound(vVALs, 2) To UBound(vVALs, 2) - 1)
'return the values to the worksheet
.Cells(1, 3).Resize(UBound(vVALs, 1), UBound(vVALs, 2)) = vVALs
End With
End Sub
Function arrRank(val As Variant, vals As Variant, _
Optional ordr As Long = xlDescending)
Dim e As Long, n As Long
If ordr = xlAscending Then
For e = LBound(vals, 1) To UBound(vals, 1)
n = n - CBool(vals(e, 1) <= val)
Next e
Else
For e = LBound(vals, 1) To UBound(vals, 1)
n = n - CBool(vals(e, 1) >= val)
Next e
End If
arrRank = n
End Function
I ran it repeatedly against the original values with a CF rule that highlighted duplicates and never found one.
A permutation which moves everything is called a derangement. A classic result in probability is that the probability of a randomly chosen permutation being a derangement is approximately 1/e (where e = 2.71828... is the natural base). This is roughly 37%. Thus -- generating random permutations until you get a derangement is almost certain to work fairly rapidly. Doing anything otherwise risks introducing subtle biases in the distribution of the derangments generated. Of course, you should have the code itself loop until it succeeds rather than rerunning it yourself.

Resources